455 lines
17 KiB
Go
455 lines
17 KiB
Go
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"
|
|
"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
|
|
}
|
|
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,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.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,''),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.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) 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 && owner.String != "schedule" {
|
|
return fault.Conflict
|
|
}
|
|
now, err := dbNow(ctx, tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
next := pulse.TaskCancelled
|
|
var completed *time.Time = &now
|
|
if task.EffectUncertain {
|
|
next = pulse.TaskNeedsReview
|
|
completed = nil
|
|
}
|
|
result, err := tx.ExecContext(ctx, `UPDATE pulse_tasks SET state=?,cancel_requested_at=?,completed_at=?,updated_at=?,revision=revision+1 WHERE namespace_id=? AND id=? AND revision=? AND state='WAITING'`, next, now, completed, now, 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", resultEvent(out.Task.State), map[string]any{"task_id": id, "state": out.Task.State, "reason": "CANCEL_REQUESTED", "effect_uncertain": out.Task.EffectUncertain}); 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
|
|
}
|