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

404 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mysql
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
const archiveMaxBytes = 8 << 20
const archiveMaxRows = 10000
// 查询定义只来自固定目录;浏览器不能提交表名、列名或SQL条件。
type archiveSource struct {
kind, table, clock, predicate string
days uint32
}
func archiveSources(p retention.Policy) []archiveSource {
return []archiveSource{
{"tasks", "pulse_tasks", "completed_at", `state IN ('SUCCEEDED','FAILED','CANCELLED') AND effect_uncertain=FALSE AND lease_until IS NULL AND owner_kind IS NULL`, p.TaskHistoryDays},
{"workflows", "weave_instances", "completed_at", `state IN ('SUCCEEDED','FAILED','CANCELLED') AND NOT EXISTS (SELECT 1 FROM pulse_tasks t WHERE t.namespace_id=r.namespace_id AND t.gate_id=r.gate_id AND (t.state NOT IN ('SUCCEEDED','FAILED','CANCELLED') OR t.effect_uncertain=TRUE)) AND NOT EXISTS (SELECT 1 FROM weave_inbox i WHERE i.namespace_id=r.namespace_id AND i.workflow_id=r.id AND i.state<>'PROCESSED')`, p.WorkflowHistoryDays},
{"events", "orbit_events", "occurred_at", `NOT EXISTS (SELECT 1 FROM orbit_deliveries d WHERE d.namespace_id=r.namespace_id AND d.event_id=r.id AND d.state<>'ACKED') AND NOT EXISTS (SELECT 1 FROM weave_inbox i WHERE i.namespace_id=r.namespace_id AND i.event_id=r.id AND i.state<>'PROCESSED')`, p.EventLogDays},
{"audit", "orbit_audit_events", "created_at", "TRUE", p.AuditLogDays},
{"deliveries", "orbit_deliveries", "acknowledged_at", "state='ACKED'", p.DeliveryHistoryDays},
{"inbox", "weave_inbox", "processed_at", `state='PROCESSED' AND EXISTS (SELECT 1 FROM weave_instances w WHERE w.namespace_id=r.namespace_id AND w.id=r.workflow_id AND w.state IN ('SUCCEEDED','FAILED','CANCELLED'))`, p.InboxHistoryDays},
{"idempotency", "orbit_idempotency_keys", "created_at", "expires_at<UTC_TIMESTAMP(6)", p.IdempotencyDays},
}
}
type archiveCursor struct {
Namespace core.ID
Kind string
Revision uint64
Cutoff time.Time
Time time.Time
ID core.ID
}
type archiveRoot struct {
id core.ID
at time.Time
}
func archiveCandidates(ctx context.Context, tx *sql.Tx, ns core.ID, source archiveSource, cutoff time.Time, limit int, cursor *archiveCursor) ([]archiveRoot, error) {
q := `SELECT /*+ MAX_EXECUTION_TIME(1500) */ r.id,r.` + source.clock + ` FROM ` + source.table + ` r WHERE r.namespace_id=? AND r.` + source.clock + `<? AND (` + source.predicate + `)`
args := []any{ns, cutoff}
if cursor != nil {
q += ` AND (r.` + source.clock + `>? OR (r.` + source.clock + `=? AND r.id>?))`
args = append(args, cursor.Time, cursor.Time, cursor.ID)
}
q += ` ORDER BY r.` + source.clock + `,r.id LIMIT ?`
args = append(args, limit)
rows, err := tx.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []archiveRoot
for rows.Next() {
var r archiveRoot
if err := rows.Scan(&r.id, &r.at); err != nil {
return nil, err
}
items = append(items, r)
}
return items, rows.Err()
}
func (s *RetentionPolicyStore) PreviewRetention(ctx context.Context, t auth.Token) (retention.Preview, error) {
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
out := retention.Preview{SampleLimit: 100, Items: []retention.CandidatePage{}}
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
if _, err := retentionActor(ctx, tx, t); err != nil {
return err
}
p, err := s.readPolicy(ctx, tx, t.NamespaceID, false)
if err != nil {
return err
}
out.Items = nil // WithTx 发生提交前重试时丢弃前次样本。
out.Policy = p
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
for _, source := range archiveSources(p) {
cutoff := now.Add(-time.Duration(source.days) * 24 * time.Hour)
roots, err := archiveCandidates(ctx, tx, t.NamespaceID, source, cutoff, 101, nil)
if err != nil {
return err
}
out.Items = append(out.Items, retention.CandidatePage{Kind: source.kind, Cutoff: cutoff, Count: min(len(roots), 100), More: len(roots) > 100})
}
return nil
})
return out, err
}
// archiveData 是独立历史快照,不是迁移/灾难恢复备份,也不是源数据可删除的证明。
type archiveData struct {
Policy retention.Policy `json:"policy"` // Policy 冻结的保留策略。
Kind string `json:"kind"` // Kind 当前批次根资源类别。
SnapshotAt time.Time `json:"snapshot_at"` // SnapshotAt 读取数据库时间。
Cutoff time.Time `json:"cutoff"` // Cutoff 当前批次截止时间。
SourceRetained bool `json:"source_retained"` // SourceRetained 表示源数据库全部记录仍然保留。
RootCount int `json:"root_count"` // RootCount 本批根记录数量。
NextCursor string `json:"next_cursor"` // NextCursor 后续批次位置,非提交水位。
Tables map[string][]map[string]any `json:"tables"` // Tables 固定表目录下的历史快照;二进制为十六进制,JSON及整数为文本以保留精度。
}
func (s *RetentionPolicyStore) ExportRetention(ctx context.Context, t auth.Token, in retention.ArchiveInput) (retention.ArchiveSnapshot, error) {
return s.exportRetention(ctx, t.NamespaceID, in, func(ctx context.Context, tx *sql.Tx) (string, error) {
actor, err := retentionActor(ctx, tx, t)
return "console:" + string(actor), err
})
}
// ExportRetentionForJob 仅供持有数据库管理权限的归档 CLI 使用,不注册为 HTTP 路由。
// 每批核验应用/命名空间启用状态和数据库策略版本;作业标识进入审计,不能冒充控制台用户。
func (s *RetentionPolicyStore) ExportRetentionForJob(ctx context.Context, namespace, job core.ID, in retention.ArchiveInput) (retention.ArchiveSnapshot, error) {
if namespace.Validate() != nil || job.Validate() != nil {
return retention.ArchiveSnapshot{}, fault.Invalid("invalid archive job identity")
}
return s.exportRetention(ctx, namespace, in, func(ctx context.Context, tx *sql.Tx) (string, error) {
var enabled bool
err := tx.QueryRowContext(ctx, `SELECT a.enabled AND n.enabled FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=? FOR SHARE`, namespace).Scan(&enabled)
if err != nil {
return "", err
}
if !enabled {
return "", fault.Forbidden
}
return "archive:" + string(job), nil
})
}
func (s *RetentionPolicyStore) exportRetention(ctx context.Context, namespace core.ID, in retention.ArchiveInput, authorize func(context.Context, *sql.Tx) (string, error)) (retention.ArchiveSnapshot, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if in.Limit == 0 {
in.Limit = 20
}
if in.Limit < 1 || in.Limit > 100 {
return retention.ArchiveSnapshot{}, fault.Invalid("limit must be 1-100")
}
// 导出不自动重放事务;失败没有成功包,源数据始终保留。
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
if err != nil {
return retention.ArchiveSnapshot{}, err
}
defer tx.Rollback()
actor, err := authorize(ctx, tx)
if err != nil {
return retention.ArchiveSnapshot{}, err
}
p, err := s.readPolicy(ctx, tx, namespace, true)
if err != nil {
return retention.ArchiveSnapshot{}, err
}
if !p.Enabled || p.Revision != in.ExpectedRevision {
return retention.ArchiveSnapshot{}, fault.Conflict
}
var source archiveSource
for _, v := range archiveSources(p) {
if v.kind == in.Kind {
source = v
}
}
if source.kind == "" {
return retention.ArchiveSnapshot{}, fault.Invalid("unsupported archive kind")
}
now, err := dbNow(ctx, tx)
if err != nil {
return retention.ArchiveSnapshot{}, err
}
cutoff := now.Add(-time.Duration(source.days) * 24 * time.Hour)
var cursor *archiveCursor
if in.Cursor != "" {
if len(in.Cursor) > 2048 {
return retention.ArchiveSnapshot{}, fault.Invalid("invalid archive cursor")
}
b, e := base64.RawURLEncoding.DecodeString(in.Cursor)
var c archiveCursor
if e != nil || json.Unmarshal(b, &c) != nil || c.Namespace != namespace || c.Kind != in.Kind || c.Revision != p.Revision || c.ID.Validate() != nil || c.Cutoff.After(cutoff) || c.Time.IsZero() || !c.Time.Before(c.Cutoff) {
return retention.ArchiveSnapshot{}, fault.Invalid("invalid archive cursor")
}
cursor = &c
cutoff = c.Cutoff
}
roots, err := archiveCandidates(ctx, tx, namespace, source, cutoff, in.Limit+1, cursor)
if err != nil {
return retention.ArchiveSnapshot{}, err
}
data := archiveData{Policy: p, Kind: in.Kind, SnapshotAt: now, Cutoff: cutoff, SourceRetained: true, Tables: map[string][]map[string]any{}}
if len(roots) > in.Limit {
roots = roots[:in.Limit]
last := roots[len(roots)-1]
raw, _ := json.Marshal(archiveCursor{namespace, in.Kind, p.Revision, cutoff, last.at, last.id})
data.NextCursor = base64.RawURLEncoding.EncodeToString(raw)
}
data.RootCount = len(roots)
budget := archiveBudget{}
for _, root := range roots {
if err := archiveRows(ctx, tx, &data, &budget, source.table, "id=?", root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
if source.kind == "tasks" {
if err := archiveTaskHistory(ctx, tx, &data, &budget, root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
}
if source.kind == "events" {
if err := archiveRows(ctx, tx, &data, &budget, "orbit_result_events", "id=?", root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
}
if source.kind == "workflows" {
for _, table := range []string{"weave_steps", "weave_activations", "weave_waits", "weave_signals", "weave_poll_states", "weave_poll_rounds", "weave_inbox"} {
if err := archiveRows(ctx, tx, &data, &budget, table, "workflow_id=?", root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
}
// 流程固定版本与全部历史关联任务也一起导出,不能只保存实例头。
if err := archiveRows(ctx, tx, &data, &budget, "weave_definition_versions", "id IN (SELECT definition_version_id FROM weave_instances WHERE namespace_id=? AND id=?)", namespace, root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
taskRows, err := tx.QueryContext(ctx, `SELECT id FROM pulse_tasks WHERE namespace_id=? AND gate_id=(SELECT gate_id FROM weave_instances WHERE namespace_id=? AND id=?) LIMIT 10001`, namespace, namespace, root.id)
if err != nil {
return retention.ArchiveSnapshot{}, err
}
var taskIDs []core.ID
for taskRows.Next() {
var id core.ID
if err := taskRows.Scan(&id); err != nil {
taskRows.Close()
return retention.ArchiveSnapshot{}, err
}
taskIDs = append(taskIDs, id)
}
err = taskRows.Err()
taskRows.Close()
if err != nil {
return retention.ArchiveSnapshot{}, err
}
if len(taskIDs) > archiveMaxRows {
return retention.ArchiveSnapshot{}, fault.Invalid("archive too large; use a smaller batch")
}
for _, id := range taskIDs {
if err := archiveRows(ctx, tx, &data, &budget, "pulse_tasks", "id=?", id); err != nil {
return retention.ArchiveSnapshot{}, err
}
if err := archiveTaskHistory(ctx, tx, &data, &budget, id); err != nil {
return retention.ArchiveSnapshot{}, err
}
}
if err := archiveRows(ctx, tx, &data, &budget, "orbit_events", "workflow_id=?", root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
if err := archiveRows(ctx, tx, &data, &budget, "orbit_audit_events", "resource_kind='workflow' AND resource_id=?", root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
}
}
content, err := json.Marshal(data)
if err != nil {
return retention.ArchiveSnapshot{}, err
}
if len(content) > archiveMaxBytes {
return retention.ArchiveSnapshot{}, fault.Invalid("archive too large; use a smaller batch")
}
sum := sha256.Sum256(content)
digest := hex.EncodeToString(sum[:])
// 只记录包摘要和数量,不将归档正文或秘密写到审计/日志。
op, err := core.NewID()
if err != nil {
return retention.ArchiveSnapshot{}, err
}
audit, err := core.NewID()
if err != nil {
return retention.ArchiveSnapshot{}, err
}
info, _ := json.Marshal(map[string]any{"kind": in.Kind, "policy_revision": p.Revision, "root_count": len(roots), "rows": budget.rows, "sha256": digest, "source_retained": true})
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,`after`) VALUES (?,?,?,?,'retention.archive.export','retention_policy',?,'export retained history snapshot',?)", namespace, audit, op, actor, namespace, string(info))
if err != nil {
return retention.ArchiveSnapshot{}, err
}
if err := tx.Commit(); err != nil {
return retention.ArchiveSnapshot{}, err
}
return retention.ArchiveSnapshot{FormatVersion: 1, SHA256: digest, Content: string(content)}, nil
}
type archiveBudget struct{ rows, bytes int }
func archiveTaskHistory(ctx context.Context, tx *sql.Tx, d *archiveData, b *archiveBudget, id core.ID) error {
if err := archiveRows(ctx, tx, d, b, "pulse_attempts", "task_id=?", id); err != nil {
return err
}
rows, err := tx.QueryContext(ctx, "SELECT id FROM pulse_attempts WHERE namespace_id=? AND task_id=? ORDER BY id LIMIT ?", d.Policy.NamespaceID, id, archiveMaxRows+1)
if err != nil {
return err
}
defer rows.Close()
var attemptIDs []core.ID
for rows.Next() {
var attemptID core.ID
if err := rows.Scan(&attemptID); err != nil {
return err
}
attemptIDs = append(attemptIDs, attemptID)
}
if err := rows.Err(); err != nil {
return err
}
if len(attemptIDs) > archiveMaxRows {
return fault.Invalid("archive too large; use a smaller batch")
}
for _, attemptID := range attemptIDs {
for _, table := range []string{"pulse_attempt_receipts", "pulse_attempt_evidence"} {
if err := archiveRows(ctx, tx, d, b, table, "attempt_id=?", attemptID); err != nil {
return err
}
}
}
if err := archiveRows(ctx, tx, d, b, "orbit_result_events", "id IN (SELECT id FROM orbit_events WHERE namespace_id=? AND aggregate_kind='task' AND aggregate_id=?)", d.Policy.NamespaceID, id); err != nil {
return err
}
if err := archiveRows(ctx, tx, d, b, "orbit_events", "aggregate_kind='task' AND aggregate_id=?", id); err != nil {
return err
}
return archiveRows(ctx, tx, d, b, "orbit_audit_events", "resource_kind='task' AND resource_id=?", id)
}
// archiveRows 仅由上方固定目录调用。密钥摘要不导出,业务JSON文本及大整数不经过浮点转换。
func archiveRows(ctx context.Context, tx *sql.Tx, d *archiveData, b *archiveBudget, table, where string, args ...any) error {
q := "SELECT /*+ MAX_EXECUTION_TIME(1500) */ * FROM " + table + " WHERE namespace_id=? AND " + where + " ORDER BY id LIMIT ?"
params := append([]any{d.Policy.NamespaceID}, args...)
params = append(params, archiveMaxRows-b.rows+1)
rows, err := tx.QueryContext(ctx, q, params...)
if err != nil {
return err
}
defer rows.Close()
cols, err := rows.ColumnTypes()
if err != nil {
return err
}
for rows.Next() {
values := make([]any, len(cols))
dest := make([]any, len(cols))
for i := range values {
dest[i] = &values[i]
}
if err := rows.Scan(dest...); err != nil {
return err
}
record := map[string]any{}
for i, col := range cols {
name := col.Name()
if strings.Contains(name, "secret") || name == "token_digest" {
continue
}
v := values[i]
switch x := v.(type) {
case []byte:
if col.DatabaseTypeName() == "BINARY" || col.DatabaseTypeName() == "VARBINARY" {
v = hex.EncodeToString(x)
} else {
v = string(x)
}
case time.Time:
v = x.UTC().Format(time.RFC3339Nano)
case int64, uint64, float64:
v = fmt.Sprint(x)
}
record[name] = v
}
raw, err := json.Marshal(record)
if err != nil {
return err
}
b.rows++
b.bytes += len(raw)
if b.rows > archiveMaxRows || b.bytes > archiveMaxBytes {
return fault.Invalid("archive too large; use a smaller batch")
}
d.Tables[table] = append(d.Tables[table], record)
}
return rows.Err()
}
var _ retention.ArchiveStore = (*RetentionPolicyStore)(nil)