feat: deliver authenticated task APIs, SDK and P1 regression
This commit is contained in:
30
internal/fault/fault.go
Normal file
30
internal/fault/fault.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Package fault 定义不包含数据库或敏感参数的稳定业务错误。
|
||||
package fault
|
||||
|
||||
import "errors"
|
||||
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return e.Code + ": " + e.Message }
|
||||
func New(code, message string) *Error { return &Error{Code: code, Message: message} }
|
||||
func Code(err error) string {
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
return e.Code
|
||||
}
|
||||
return "UNAVAILABLE"
|
||||
}
|
||||
|
||||
var (
|
||||
Unauthorized = New("UNAUTHENTICATED", "valid credentials are required")
|
||||
Forbidden = New("PERMISSION_DENIED", "operation is not allowed")
|
||||
NotFound = New("NOT_FOUND", "resource not found")
|
||||
Conflict = New("STATE_CONFLICT", "resource state does not allow this operation")
|
||||
IdempotencyConflict = New("IDEMPOTENCY_CONFLICT", "idempotency key was used with different content")
|
||||
Unavailable = New("UNAVAILABLE", "service temporarily unavailable; retry using the original operation identity")
|
||||
)
|
||||
|
||||
func Invalid(message string) error { return New("INVALID_ARGUMENT", message) }
|
||||
194
internal/jsonvalue/canonical.go
Normal file
194
internal/jsonvalue/canonical.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Package jsonvalue 比较 JSON 的实际内容,不将 JSON 数值转换为浮点数。
|
||||
package jsonvalue
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Canonical 生成内部 orbit-json-v1 摘要输入:对象键排序、重复键拒绝、
|
||||
// 精确十进制数规范化。该格式不是对外 JSON 编码承诺,也不宣称实现 RFC 8785。
|
||||
func Canonical(raw []byte) ([]byte, error) {
|
||||
if !utf8.Valid(raw) {
|
||||
return nil, errors.New("JSON must be valid UTF-8")
|
||||
}
|
||||
if err := validEscapes(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
b, err := value(d, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := d.Token(); err != io.EOF {
|
||||
return nil, errors.New("exactly one JSON value required")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
func value(d *json.Decoder, depth int) ([]byte, error) {
|
||||
if depth > 32 {
|
||||
return nil, errors.New("JSON nesting exceeds 32 levels")
|
||||
}
|
||||
t, err := d.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch v := t.(type) {
|
||||
case json.Delim:
|
||||
switch v {
|
||||
case '{':
|
||||
fields := map[string][]byte{}
|
||||
keys := []string{}
|
||||
for d.More() {
|
||||
token, err := d.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, ok := token.(string)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid object key")
|
||||
}
|
||||
if _, ok := fields[key]; ok {
|
||||
return nil, errors.New("duplicate JSON object key")
|
||||
}
|
||||
b, err := value(d, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields[key] = b
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if close, err := d.Token(); err != nil || close != json.Delim('}') {
|
||||
return nil, errors.New("unterminated JSON object")
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b bytes.Buffer
|
||||
b.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
key, _ := json.Marshal(k)
|
||||
b.Write(key)
|
||||
b.WriteByte(':')
|
||||
b.Write(fields[k])
|
||||
}
|
||||
b.WriteByte('}')
|
||||
return b.Bytes(), nil
|
||||
case '[':
|
||||
var b bytes.Buffer
|
||||
b.WriteByte('[')
|
||||
n := 0
|
||||
for d.More() {
|
||||
v, err := value(d, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.Write(v)
|
||||
n++
|
||||
}
|
||||
if close, err := d.Token(); err != nil || close != json.Delim(']') {
|
||||
return nil, errors.New("unterminated JSON array")
|
||||
}
|
||||
b.WriteByte(']')
|
||||
return b.Bytes(), nil
|
||||
default:
|
||||
return nil, errors.New("unexpected JSON delimiter")
|
||||
}
|
||||
case json.Number:
|
||||
return number(string(v))
|
||||
default:
|
||||
return json.Marshal(v)
|
||||
}
|
||||
}
|
||||
func number(s string) ([]byte, error) {
|
||||
if len(s) > 256 {
|
||||
return nil, errors.New("JSON number exceeds 256 characters")
|
||||
}
|
||||
// 仅用作有限范围检查;摘要始终由下面的十进制文本运算生成。
|
||||
if _, err := strconv.ParseFloat(s, 64); err != nil {
|
||||
return nil, errors.New("JSON number outside supported range")
|
||||
}
|
||||
negative := strings.HasPrefix(s, "-")
|
||||
s = strings.TrimPrefix(s, "-")
|
||||
exponent := 0
|
||||
if i := strings.IndexAny(s, "eE"); i >= 0 {
|
||||
var err error
|
||||
exponent, err = strconv.Atoi(s[i+1:])
|
||||
if err != nil || exponent < -308 || exponent > 308 {
|
||||
return nil, errors.New("JSON exponent outside supported range")
|
||||
}
|
||||
s = s[:i]
|
||||
}
|
||||
if i := strings.IndexByte(s, '.'); i >= 0 {
|
||||
exponent -= len(s) - i - 1
|
||||
s = s[:i] + s[i+1:]
|
||||
}
|
||||
s = strings.TrimLeft(s, "0")
|
||||
if s == "" {
|
||||
return []byte("0"), nil
|
||||
}
|
||||
for strings.HasSuffix(s, "0") {
|
||||
s = s[:len(s)-1]
|
||||
exponent++
|
||||
}
|
||||
if exponent < -308 || exponent > 308 {
|
||||
return nil, errors.New("normalized JSON exponent outside supported range")
|
||||
}
|
||||
if exponent+len(s)-1 > 308 {
|
||||
return nil, errors.New("JSON number outside supported range")
|
||||
}
|
||||
if negative {
|
||||
s = "-" + s
|
||||
}
|
||||
if exponent != 0 {
|
||||
s += "e" + strconv.Itoa(exponent)
|
||||
}
|
||||
return []byte(s), nil
|
||||
}
|
||||
|
||||
// encoding/json 会替换孤立 UTF-16 代理项;拒绝它们,避免两个不同输入被悄悄合并。
|
||||
func validEscapes(raw []byte) error {
|
||||
for i := 0; i < len(raw); i++ {
|
||||
if raw[i] != '\\' {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
if i >= len(raw) || raw[i] != 'u' {
|
||||
continue
|
||||
}
|
||||
if i+4 >= len(raw) {
|
||||
return errors.New("invalid unicode escape")
|
||||
}
|
||||
v, err := strconv.ParseUint(string(raw[i+1:i+5]), 16, 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i += 4
|
||||
if v >= 0xdc00 && v <= 0xdfff {
|
||||
return errors.New("unpaired unicode surrogate")
|
||||
}
|
||||
if v < 0xd800 || v > 0xdbff {
|
||||
continue
|
||||
}
|
||||
if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
|
||||
return errors.New("unpaired unicode surrogate")
|
||||
}
|
||||
v, err = strconv.ParseUint(string(raw[i+3:i+7]), 16, 16)
|
||||
if err != nil || v < 0xdc00 || v > 0xdfff {
|
||||
return errors.New("unpaired unicode surrogate")
|
||||
}
|
||||
i += 6
|
||||
}
|
||||
return nil
|
||||
}
|
||||
38
internal/jsonvalue/canonical_test.go
Normal file
38
internal/jsonvalue/canonical_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package jsonvalue
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCanonicalPreservesExactNumbers(t *testing.T) {
|
||||
for _, pair := range [][2]string{
|
||||
{`{"z":1.0,"a":9007199254740993}`, `{"a":9007199254740993,"z":1}`},
|
||||
{`[0,-0,100,0.0001000]`, `[0,0,1e2,1e-4]`},
|
||||
{`{"x":12345678901234567890.1234567890123456789}`, `{"x":123456789012345678901234567890123456789e-19}`},
|
||||
{`"\ud83d\ude00"`, `"😀"`},
|
||||
} {
|
||||
got, err := Canonical([]byte(pair[0]))
|
||||
if err != nil || string(got) != pair[1] {
|
||||
t.Fatalf("%s => %s, %v", pair[0], got, err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{`{"a":1,"\u0061":2}`, `{"nested":{"x":1,"x":2}}`, `1 2`, `9e308`, `1e309`, `"\ud800"`, `"\udc00"`, `"\ud800\u1234"`, string([]byte{'"', 0xff, '"'})} {
|
||||
if _, err := Canonical([]byte(raw)); err == nil {
|
||||
t.Fatalf("accepted %q", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzCanonical(f *testing.F) {
|
||||
for _, s := range []string{`{"a":1.0}`, `[1,true,null]`, `"\ud83d\ude00"`, `0.0000000000000000001`} {
|
||||
f.Add(s)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
b, err := Canonical([]byte(s))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
again, err := Canonical(b)
|
||||
if err != nil || string(again) != string(b) {
|
||||
t.Fatalf("unstable canonical form %s %v", b, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
// Package httpserver exposes bootstrap health endpoints only. Task, workflow,
|
||||
// authentication and WebSocket APIs will be implemented in subsequent slices.
|
||||
// Package httpserver exposes health and authenticated task-management endpoints.
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Checker func(context.Context) error
|
||||
|
||||
func New(addr string, ready Checker) *http.Server {
|
||||
func New(addr string, ready Checker, tasks ...*service.Service) *http.Server {
|
||||
mux := http.NewServeMux()
|
||||
write := func(w http.ResponseWriter, status int, body map[string]string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -30,5 +30,8 @@ func New(addr string, ready Checker) *http.Server {
|
||||
}
|
||||
write(w, http.StatusOK, map[string]string{"status": "ready", "scope": "database_and_schema"})
|
||||
})
|
||||
return &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
|
||||
if len(tasks) > 0 && tasks[0] != nil {
|
||||
registerTasks(mux, tasks[0])
|
||||
}
|
||||
return &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
|
||||
}
|
||||
|
||||
241
internal/transport/httpserver/tasks.go
Normal file
241
internal/transport/httpserver/tasks.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
type endpoint func(*http.Request, auth.Token) (any, int, error)
|
||||
|
||||
func registerTasks(mux *http.ServeMux, s *service.Service) {
|
||||
mux.HandleFunc("POST /v1/tasks", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
var in service.SubmitInput
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Submit(r.Context(), t, key, in)
|
||||
return out, createdStatus(out.Deduplicated), err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/tasks/{id}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
out, err := s.GetTask(r.Context(), t, core.ID(r.PathValue("id")))
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/tasks", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
q, queryErr := url.ParseQuery(r.URL.RawQuery)
|
||||
if queryErr != nil {
|
||||
return nil, 0, fault.Invalid("invalid query encoding")
|
||||
}
|
||||
for k, v := range q {
|
||||
if len(v) != 1 || (k != "queue_id" && k != "state" && k != "limit" && k != "cursor") {
|
||||
return nil, 0, fault.Invalid("invalid query parameter")
|
||||
}
|
||||
}
|
||||
limit := 0
|
||||
if q.Has("limit") {
|
||||
var err error
|
||||
limit, err = strconv.Atoi(q.Get("limit"))
|
||||
if err != nil || limit <= 0 {
|
||||
return nil, 0, fault.Invalid("invalid limit")
|
||||
}
|
||||
}
|
||||
out, err := s.ListTasks(r.Context(), t, service.ListInput{QueueID: core.ID(q.Get("queue_id")), State: pulse.TaskState(q.Get("state")), Limit: limit, Cursor: q.Get("cursor")})
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("POST /v1/tasks/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
command := r.PathValue("command")
|
||||
if !strings.HasSuffix(command, ":cancel") {
|
||||
return nil, 0, fault.NotFound
|
||||
}
|
||||
var in service.CancelInput
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.CancelTask(r.Context(), t, core.ID(strings.TrimSuffix(command, ":cancel")), key, in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("POST /v1/queues", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
var in service.QueueInput
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.CreateQueue(r.Context(), t, key, in)
|
||||
return out, createdStatus(out.Deduplicated), err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/queues/{id}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
out, err := s.GetQueue(r.Context(), t, core.ID(r.PathValue("id")))
|
||||
return out, 200, err
|
||||
}))
|
||||
}
|
||||
func createdStatus(duplicate bool) int {
|
||||
if duplicate {
|
||||
return http.StatusOK
|
||||
}
|
||||
return http.StatusCreated
|
||||
}
|
||||
func idempotencyKey(r *http.Request) (string, error) {
|
||||
values := r.Header.Values("Idempotency-Key")
|
||||
if len(values) != 1 {
|
||||
return "", fault.Invalid("exactly one Idempotency-Key is required")
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
func wrap(next endpoint) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := core.NewID()
|
||||
if err != nil {
|
||||
writeError(w, "", fault.Unavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-Request-ID", string(id))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
values := r.Header.Values("Authorization")
|
||||
var token auth.Token
|
||||
if len(values) != 1 {
|
||||
writeError(w, string(id), fault.Unauthorized)
|
||||
return
|
||||
}
|
||||
scheme, value, ok := strings.Cut(values[0], " ")
|
||||
if !ok || !strings.EqualFold(scheme, "Bearer") {
|
||||
writeError(w, string(id), fault.Unauthorized)
|
||||
return
|
||||
}
|
||||
token, err = auth.Parse(value)
|
||||
if err != nil {
|
||||
writeError(w, string(id), fault.Unauthorized)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
data, status, err := next(r.WithContext(ctx), token)
|
||||
if err != nil {
|
||||
if fault.Code(err) == "UNAVAILABLE" {
|
||||
slog.Error("Orbit API operation failed", "request_id", id, "code", "UNAVAILABLE")
|
||||
}
|
||||
writeError(w, string(id), err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"request_id": id, "data": data})
|
||||
}
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
status = 503
|
||||
raw = []byte(`{"error":{"code":"UNAVAILABLE","message":"operation unavailable"}}`)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(append(raw, '\n'))
|
||||
}
|
||||
func writeError(w http.ResponseWriter, id string, err error) {
|
||||
var known *fault.Error
|
||||
if !errors.As(err, &known) {
|
||||
known = fault.Unavailable
|
||||
}
|
||||
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415}[known.Code]
|
||||
if status == 0 {
|
||||
status = 503
|
||||
}
|
||||
if status == 401 {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="orbit"`)
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"request_id": id, "error": known})
|
||||
}
|
||||
func decodeBody(r *http.Request, out any) error {
|
||||
content, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || content != "application/json" {
|
||||
return fault.New("UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json")
|
||||
}
|
||||
const maxBody = 512 << 10
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
|
||||
if err != nil {
|
||||
return fault.Invalid("cannot read request body")
|
||||
}
|
||||
if len(raw) > maxBody {
|
||||
return fault.New("BODY_TOO_LARGE", "request body exceeds 512 KiB")
|
||||
}
|
||||
if _, err := jsonvalue.Canonical(raw); err != nil {
|
||||
return fault.Invalid("invalid JSON, duplicate keys, or unsupported depth/numeric range")
|
||||
}
|
||||
if err := exactFields(raw, reflect.TypeOf(out).Elem()); err != nil {
|
||||
return fault.Invalid("unknown field, null, or invalid object structure")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(out); err != nil {
|
||||
return fault.Invalid("invalid field value or type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 精确匹配结构字段,防止 encoding/json 将 Handler 和 handler 解释为同一个字段。
|
||||
func exactFields(raw []byte, t reflect.Type) error {
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
if t.Kind() == reflect.Pointer {
|
||||
return nil
|
||||
}
|
||||
return errors.New("unexpected null")
|
||||
}
|
||||
if t.Kind() == reflect.Pointer {
|
||||
return exactFields(raw, t.Elem())
|
||||
}
|
||||
if t == reflect.TypeOf(time.Time{}) || t == reflect.TypeOf(json.RawMessage{}) {
|
||||
return nil
|
||||
}
|
||||
if t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
var object map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &object); err != nil {
|
||||
return err
|
||||
}
|
||||
names := map[string]reflect.Type{}
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
name := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if name != "" && name != "-" {
|
||||
names[name] = field.Type
|
||||
}
|
||||
}
|
||||
for name, value := range object {
|
||||
field, ok := names[name]
|
||||
if !ok {
|
||||
return errors.New("unknown field")
|
||||
}
|
||||
if err := exactFields(value, field); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
44
internal/transport/httpserver/tasks_test.go
Normal file
44
internal/transport/httpserver/tasks_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
func TestBodyAndErrorBoundary(t *testing.T) {
|
||||
for _, test := range []struct{ body, content, code string }{
|
||||
{`{"payload":{"n":1,"n":2}}`, "application/json", "INVALID_ARGUMENT"},
|
||||
{`{"retry":{"MaxAttempts":2}}`, "application/json", "INVALID_ARGUMENT"},
|
||||
{`{"retry":{"max_attempts":1},"Retry":{"max_attempts":2}}`, "application/json", "INVALID_ARGUMENT"},
|
||||
{`{}`, "text/plain", "UNSUPPORTED_MEDIA_TYPE"},
|
||||
{`{"payload":{"arbitrary_key":9007199254740993}}`, "application/json", ""},
|
||||
} {
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/tasks", strings.NewReader(test.body))
|
||||
r.Header.Set("Content-Type", test.content)
|
||||
var in service.SubmitInput
|
||||
err := decodeBody(r, &in)
|
||||
if test.code == "" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if fault.Code(err) != test.code {
|
||||
t.Fatalf("body %s: %v", test.body, err)
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
writeError(w, "request", fault.Forbidden)
|
||||
var envelope struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &envelope); err != nil || envelope.Error.Code != "PERMISSION_DENIED" || w.Code != 403 {
|
||||
t.Fatalf("bad error contract %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user