Files
orbit/internal/storage/mysql/task_store.go

384 lines
15 KiB
Go

package mysql
import (
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
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 := jsonwire.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
}
if _, _, err := lockQueue(ctx, tx, token.NamespaceID, in.QueueID); 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
}
if err := reserveQueueTask(ctx, tx, token.NamespaceID, in.QueueID); 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,concurrency_key,concurrency_key_digest,priority,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, concurrencyKeyValue(in.ConcurrencyKey), concurrencyDigest(in.ConcurrencyKey), in.Priority, 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,''),COALESCE(concurrency_key,''),priority,available_at,start_deadline_at,deadline_at,recovery_safety,policy_snapshot,attempt_count,cancel_requested_at,completed_at,created_at,updated_at,effect_uncertain`
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.ConcurrencyKey, &v.Priority, &v.AvailableAt, &v.StartDeadlineAt, &v.DeadlineAt, &v.RecoverySafety, &policy, &v.AttemptCount, &v.CancelRequestedAt, &v.CompletedAt, &v.CreatedAt, &v.UpdatedAt, &v.EffectUncertain)
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,''),COALESCE(concurrency_key,''),priority,available_at,deadline_at,attempt_count,created_at,updated_at,effect_uncertain 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.ConcurrencyKey, &v.Priority, &v.AvailableAt, &v.DeadlineAt, &v.AttemptCount, &v.CreatedAt, &v.UpdatedAt, &v.EffectUncertain); 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) 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
}
maxUnfinished := in.MaxUnfinishedTasks
if maxUnfinished == 0 {
maxUnfinished = 100000
}
_, err = tx.ExecContext(ctx, "INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,max_unfinished_tasks,policy) VALUES (?,?,?,?,?,?,'{}')", token.NamespaceID, id, in.Name, in.MaxConcurrency, in.RatePerSecond, maxUnfinished)
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,max_unfinished_tasks,unfinished_tasks,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.MaxUnfinishedTasks, &q.UnfinishedTasks, &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
}