feat: add execution history queries and preserve bounded JSON payloads
This commit is contained in:
224
internal/storage/mysql/task_history.go
Normal file
224
internal/storage/mysql/task_history.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
// historyCursor 只定位已授权父资源中的后续位置,不作为身份或授权证明。
|
||||
type historyCursor struct {
|
||||
Version int `json:"v"`
|
||||
Kind string `json:"k"`
|
||||
Namespace core.ID `json:"n"`
|
||||
Task core.ID `json:"t"`
|
||||
Attempt core.ID `json:"a,omitempty"`
|
||||
Epoch uint64 `json:"e,omitempty,string"`
|
||||
Created *time.Time `json:"c,omitempty"`
|
||||
ID core.ID `json:"i,omitempty"`
|
||||
}
|
||||
|
||||
func decodeHistoryCursor(value string, ns, task, attempt core.ID) (historyCursor, error) {
|
||||
var c historyCursor
|
||||
raw, err := base64.RawURLEncoding.Strict().DecodeString(value)
|
||||
if err != nil || len(raw) > 768 {
|
||||
return c, fault.Invalid("invalid history cursor")
|
||||
}
|
||||
if _, err := jsonvalue.Canonical(raw); err != nil {
|
||||
return c, fault.Invalid("invalid history cursor")
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &fields) != nil || fields == nil {
|
||||
return c, fault.Invalid("invalid history cursor")
|
||||
}
|
||||
for k := range fields {
|
||||
switch k {
|
||||
case "v", "k", "n", "t", "a", "e", "c", "i":
|
||||
default:
|
||||
return c, fault.Invalid("invalid history cursor")
|
||||
}
|
||||
}
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(&c) != nil || c.Version != 1 || c.Namespace != ns || c.Task != task || c.Attempt != attempt {
|
||||
return c, fault.Invalid("cursor does not match this history query")
|
||||
}
|
||||
if attempt == "" {
|
||||
if c.Kind != "attempts" || c.Epoch == 0 || c.ID != "" || c.Created != nil {
|
||||
return c, fault.Invalid("invalid attempts cursor")
|
||||
}
|
||||
} else {
|
||||
if c.Kind != "evidence" || c.Epoch != 0 || c.ID.Validate() != nil || c.Created == nil || c.Created.Year() < 2000 || c.Created.Year() > 9998 || c.Created.Nanosecond()%1000 != 0 {
|
||||
return c, fault.Invalid("invalid evidence cursor")
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
func encodeHistoryCursor(c historyCursor) (string, error) {
|
||||
raw, err := json.Marshal(c)
|
||||
return base64.RawURLEncoding.EncodeToString(raw), err
|
||||
}
|
||||
func authorizeHistoryTask(ctx context.Context, tx *sql.Tx, ns, task core.ID, p auth.Permissions) error {
|
||||
var queue core.ID
|
||||
err := tx.QueryRowContext(ctx, `SELECT queue_id FROM pulse_tasks WHERE namespace_id=? AND id=?`, ns, task).Scan(&queue)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.AllowsQueue(queue) {
|
||||
return fault.NotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func historyAttemptExists(ctx context.Context, tx *sql.Tx, ns, task, attempt core.ID) error {
|
||||
var found core.ID
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM pulse_attempts WHERE namespace_id=? AND task_id=? AND id=?`, ns, task, attempt).Scan(&found)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
const attemptHistoryColumns = `a.id,a.task_id,a.epoch,a.state,a.revision,a.lease_until,a.authorized_at,a.ended_at,COALESCE(a.error_code,''),a.created_at,a.updated_at`
|
||||
|
||||
func scanAttemptSummary(row interface{ Scan(...any) error }) (service.AttemptSummary, error) {
|
||||
var a service.AttemptSummary
|
||||
err := row.Scan(&a.ID, &a.TaskID, &a.Epoch, &a.State, &a.Revision, &a.LeaseUntil, &a.AuthorizedAt, &a.EndedAt, &a.ErrorCode, &a.CreatedAt, &a.UpdatedAt)
|
||||
return a, err
|
||||
}
|
||||
func (s *TaskStore) ListTaskAttempts(ctx context.Context, t auth.Token, task core.ID, in service.HistoryPageInput) (service.AttemptPage, error) {
|
||||
out := service.AttemptPage{Items: []service.AttemptSummary{}}
|
||||
err := s.transaction(ctx, t, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if err := authorizeHistoryTask(ctx, tx, t.NamespaceID, task, p); err != nil {
|
||||
return err
|
||||
}
|
||||
q := `SELECT ` + attemptHistoryColumns + ` FROM pulse_attempts a WHERE a.namespace_id=? AND a.task_id=?`
|
||||
args := []any{t.NamespaceID, task}
|
||||
if in.Cursor != "" {
|
||||
c, err := decodeHistoryCursor(in.Cursor, t.NamespaceID, task, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q += ` AND a.epoch<?`
|
||||
args = append(args, c.Epoch)
|
||||
}
|
||||
q += ` ORDER BY a.epoch 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() {
|
||||
a, err := scanAttemptSummary(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, a)
|
||||
}
|
||||
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]
|
||||
out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "attempts", Namespace: t.NamespaceID, Task: task, Epoch: last.Epoch})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *TaskStore) GetTaskAttempt(ctx context.Context, t auth.Token, task, attempt core.ID) (service.AttemptView, error) {
|
||||
var out service.AttemptView
|
||||
err := s.transaction(ctx, t, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if err := authorizeHistoryTask(ctx, tx, t.NamespaceID, task, p); err != nil {
|
||||
return err
|
||||
}
|
||||
var result []byte
|
||||
err := tx.QueryRowContext(ctx, `SELECT `+attemptHistoryColumns+`,a.session_id,w.worker_instance_id,a.result FROM pulse_attempts a JOIN pulse_worker_sessions w ON w.namespace_id=a.namespace_id AND w.id=a.session_id WHERE a.namespace_id=? AND a.task_id=? AND a.id=?`, t.NamespaceID, task, attempt).Scan(&out.ID, &out.TaskID, &out.Epoch, &out.State, &out.Revision, &out.LeaseUntil, &out.AuthorizedAt, &out.EndedAt, &out.ErrorCode, &out.CreatedAt, &out.UpdatedAt, &out.SessionID, &out.WorkerInstanceID, &result)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
out.Result = result
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *TaskStore) ListAttemptEvidence(ctx context.Context, t auth.Token, task, attempt core.ID, in service.HistoryPageInput) (service.EvidencePage, error) {
|
||||
out := service.EvidencePage{Items: []service.EvidenceSummary{}}
|
||||
err := s.transaction(ctx, t, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if err := authorizeHistoryTask(ctx, tx, t.NamespaceID, task, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := historyAttemptExists(ctx, tx, t.NamespaceID, task, attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
q := `SELECT id,attempt_id,outcome,COALESCE(error_code,''),reason,created_at FROM pulse_attempt_evidence WHERE namespace_id=? AND attempt_id=?`
|
||||
args := []any{t.NamespaceID, attempt}
|
||||
if in.Cursor != "" {
|
||||
c, err := decodeHistoryCursor(in.Cursor, t.NamespaceID, task, attempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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() {
|
||||
e := service.EvidenceSummary{TaskID: task, Disposition: "EVIDENCE_ONLY"}
|
||||
if err := rows.Scan(&e.ID, &e.AttemptID, &e.Outcome, &e.ErrorCode, &e.Reason, &e.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, e)
|
||||
}
|
||||
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]
|
||||
out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "evidence", Namespace: t.NamespaceID, Task: task, Attempt: attempt, Created: &last.CreatedAt, ID: last.ID})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *TaskStore) GetAttemptEvidence(ctx context.Context, t auth.Token, task, attempt, evidence core.ID) (service.EvidenceView, error) {
|
||||
out := service.EvidenceView{EvidenceSummary: service.EvidenceSummary{TaskID: task, Disposition: "EVIDENCE_ONLY"}}
|
||||
err := s.transaction(ctx, t, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if err := authorizeHistoryTask(ctx, tx, t.NamespaceID, task, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := historyAttemptExists(ctx, tx, t.NamespaceID, task, attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
var payload []byte
|
||||
err := tx.QueryRowContext(ctx, `SELECT id,attempt_id,outcome,COALESCE(error_code,''),reason,created_at,message_id,payload FROM pulse_attempt_evidence WHERE namespace_id=? AND attempt_id=? AND id=?`, t.NamespaceID, attempt, evidence).Scan(&out.ID, &out.AttemptID, &out.Outcome, &out.ErrorCode, &out.Reason, &out.CreatedAt, &out.MessageID, &payload)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
out.Payload = payload
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
Reference in New Issue
Block a user