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