feat: add retention policy and stability verification

This commit is contained in:
2026-09-22 16:19:21 +08:00
parent c33c12c5e9
commit 2f95d9c45c
30 changed files with 1633 additions and 18 deletions

View File

@@ -92,6 +92,9 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
f.exec(`ALTER TABLE pulse_tasks DROP CHECK ck_task_concurrency_key, DROP COLUMN concurrency_key, DROP COLUMN concurrency_key_digest`)
f.exec(`ALTER TABLE pulse_attempts DROP COLUMN concurrency_token`)
f.exec(`ALTER TABLE pulse_worker_sessions DROP COLUMN concurrency_keys`)
// 新增的控制台和保留策略迁移也要还原到历史 v9,避免升级测试把
// 已经存在的 v29/v30 表误当成迁移重复执行。
f.exec(`DROP TABLE orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`)
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)

View File

@@ -1,4 +1,4 @@
-- Orbit fresh-install baseline, generated from migration version 28.
-- Orbit fresh-install baseline, generated from migration version 30.
-- This file intentionally contains no database-level relation constraints.
CREATE TABLE `orbit_application_resources` (
`application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度',
@@ -852,3 +852,29 @@ CREATE TABLE orbit_console_sessions (
KEY ix_console_session_expiry (expires_at, revoked_at),
CONSTRAINT ck_console_sessions_0 CHECK (expires_at > created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='控制台 HttpOnly 登录会话,只保存令牌摘要';
-- 命名空间级历史保留策略;只保存窗口和开关,不执行自动删除。
-- 归档执行器必须在独立事务中核对状态、备份和归档证明后再使用这些窗口。
CREATE TABLE orbit_retention_policies (
namespace_id BINARY(16) NOT NULL COMMENT '策略所属命名空间,服务端核对登录会话后使用',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '策略乐观并发版本,从1开始;首次默认策略未落库时版本为0',
enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否允许归档执行器使用策略;默认关闭,不触发删除',
idempotency_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '请求幂等记录至少保留天数,范围90至3650',
task_history_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '终态任务和执行证据至少保留天数,范围1至3650',
workflow_history_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '终态流程、步骤、等待和流程事件至少保留天数,范围1至3650',
event_log_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '状态事件和结果事件至少保留天数,范围1至3650',
delivery_history_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '已确认结果交付至少保留天数,未确认交付不得按窗口清理',
inbox_history_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '已处理流程Inbox事件至少保留天数,未处理记录不得清理',
audit_log_days INT UNSIGNED NOT NULL DEFAULT 2555 COMMENT '管理审计至少保留天数,普通清理不得处理审计记录',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '策略首次保存时间,统一UTC',
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '策略最后修改时间,统一UTC',
PRIMARY KEY (namespace_id),
CONSTRAINT ck_retention_policy_revision CHECK (revision > 0),
CONSTRAINT ck_retention_policy_idempotency CHECK (idempotency_days BETWEEN 90 AND 3650),
CONSTRAINT ck_retention_policy_task CHECK (task_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_workflow CHECK (workflow_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_event CHECK (event_log_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_delivery CHECK (delivery_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_inbox CHECK (inbox_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_audit CHECK (audit_log_days BETWEEN 1 AND 3650)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间历史数据保留策略,只配置窗口和开关,不自动删除';

View File

@@ -26,7 +26,7 @@ var migrationFiles embed.FS
//go:embed baseline.sql
var freshInstallBaseline string
const freshInstallBaselineVersion uint64 = 29
const freshInstallBaselineVersion uint64 = 30
type Migration struct {
Version uint64

View File

@@ -0,0 +1,25 @@
-- 命名空间级历史保留策略;只保存窗口和开关,不执行自动删除。
-- 归档执行器必须在独立事务中核对状态、备份和归档证明后再使用这些窗口。
CREATE TABLE orbit_retention_policies (
namespace_id BINARY(16) NOT NULL COMMENT '策略所属命名空间,服务端核对登录会话后使用',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '策略乐观并发版本,从1开始;首次默认策略未落库时版本为0',
enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否允许归档执行器使用策略;默认关闭,不触发删除',
idempotency_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '请求幂等记录至少保留天数,范围90至3650',
task_history_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '终态任务和执行证据至少保留天数,范围1至3650',
workflow_history_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '终态流程、步骤、等待和流程事件至少保留天数,范围1至3650',
event_log_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '状态事件和结果事件至少保留天数,范围1至3650',
delivery_history_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '已确认结果交付至少保留天数,未确认交付不得按窗口清理',
inbox_history_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '已处理流程Inbox事件至少保留天数,未处理记录不得清理',
audit_log_days INT UNSIGNED NOT NULL DEFAULT 2555 COMMENT '管理审计至少保留天数,普通清理不得处理审计记录',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '策略首次保存时间,统一UTC',
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '策略最后修改时间,统一UTC',
PRIMARY KEY (namespace_id),
CONSTRAINT ck_retention_policy_revision CHECK (revision > 0),
CONSTRAINT ck_retention_policy_idempotency CHECK (idempotency_days BETWEEN 90 AND 3650),
CONSTRAINT ck_retention_policy_task CHECK (task_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_workflow CHECK (workflow_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_event CHECK (event_log_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_delivery CHECK (delivery_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_inbox CHECK (inbox_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_audit CHECK (audit_log_days BETWEEN 1 AND 3650)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间历史数据保留策略,只配置窗口和开关,不自动删除';

View File

@@ -147,7 +147,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
indexes := func() []string {
t.Helper()
// 排除后续新增/替换索引;原有项在此逐项比较,v24/v25新增项由对应Query/Timeline升级测试核对。
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events','orbit_console_users','orbit_console_sessions','orbit_retention_policies','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
if err != nil {
t.Fatal(err)
}

View File

@@ -0,0 +1,377 @@
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) {
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 := retentionActor(ctx, tx, t)
if err != nil {
return retention.ArchiveSnapshot{}, err
}
p, err := s.readPolicy(ctx, tx, t.NamespaceID, 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 != t.NamespaceID || 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, t.NamespaceID, 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{t.NamespaceID, 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=?)", t.NamespaceID, 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`, t.NamespaceID, t.NamespaceID, 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',?)", t.NamespaceID, audit, op, "console:"+string(actor), t.NamespaceID, 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)

View File

@@ -0,0 +1,158 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"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"
driver "github.com/go-sql-driver/mysql"
)
const retentionPolicyOperation = "retention.policy.update"
const retentionPolicyColumns = `revision,enabled,idempotency_days,task_history_days,workflow_history_days,event_log_days,delivery_history_days,inbox_history_days,audit_log_days,updated_at`
// RetentionPolicyStore 保存命名空间级策略,数据库配置优先于部署默认值。
// 不缓存配置;节点重启和跨节点读取保持数据库中同一版本。
type RetentionPolicyStore struct {
db *sql.DB
defaults retention.Policy
}
func NewRetentionPolicyStore(db *sql.DB, defaults ...retention.Policy) *RetentionPolicyStore {
p := retention.Defaults("")
if len(defaults) > 0 {
p = defaults[0]
}
return &RetentionPolicyStore{db: db, defaults: p}
}
var _ retention.Store = (*RetentionPolicyStore)(nil)
func (s *RetentionPolicyStore) defaultPolicy(namespace core.ID) retention.Policy {
p := s.defaults
p.NamespaceID = namespace
p.Source = "default"
return p
}
type retentionQuery interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func (s *RetentionPolicyStore) readPolicy(ctx context.Context, db retentionQuery, namespace core.ID, lock bool) (retention.Policy, error) {
p := s.defaultPolicy(namespace)
q := `SELECT ` + retentionPolicyColumns + ` FROM orbit_retention_policies WHERE namespace_id=?`
if lock {
q += " FOR UPDATE"
}
err := db.QueryRowContext(ctx, q, namespace).Scan(&p.Revision, &p.Enabled, &p.IdempotencyDays, &p.TaskHistoryDays, &p.WorkflowHistoryDays, &p.EventLogDays, &p.DeliveryHistoryDays, &p.InboxHistoryDays, &p.AuditLogDays, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return p, p.Validate()
}
if err != nil {
return retention.Policy{}, err
}
p.Source = "database"
return p, p.Validate()
}
// GetRetentionPolicy 供经过会话认证的控制台和数据库管理工具读取配置。
func (s *RetentionPolicyStore) GetRetentionPolicy(ctx context.Context, namespace core.ID) (retention.Policy, error) {
if namespace.Validate() != nil {
return retention.Policy{}, fault.Invalid("invalid namespace")
}
return s.readPolicy(ctx, s.db, namespace, false)
}
// retentionActor 在策略事务内验证控制台会话。普通 SDK 凭据不能修改保留策略。
// 管理员必须具备当前命名空间全部队列的管理范围;不添加角色体系或跨命名空间权限。
func retentionActor(ctx context.Context, tx *sql.Tx, token auth.Token) (core.ID, error) {
p, err := authenticate(ctx, tx, token)
if err != nil {
return "", err
}
all := false
for _, q := range p.Queues {
if q == "*" {
all = true
}
}
if !all || !p.Allows("queues:manage") {
return "", fault.Forbidden
}
var user core.ID
err = tx.QueryRowContext(ctx, `SELECT u.id FROM orbit_console_sessions s
JOIN orbit_console_users u ON u.id=s.user_id AND u.namespace_id=s.namespace_id AND u.credential_id=s.credential_id
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=? AND s.revoked_at IS NULL
AND s.expires_at>UTC_TIMESTAMP(6) AND u.enabled=TRUE FOR SHARE`, token.NamespaceID, token.CredentialID, token.Digest).Scan(&user)
if errors.Is(err, sql.ErrNoRows) {
return "", fault.Unauthorized
}
return user, err
}
// UpdateRetentionPolicy 在同一事务中核验版本、保存策略、前后快照、账号身份和幂等回执。
func (s *RetentionPolicyStore) UpdateRetentionPolicy(ctx context.Context, token auth.Token, key string, in retention.UpdateInput) (retention.Policy, error) {
if strings.TrimSpace(key) == "" || len(key) > 128 {
return retention.Policy{}, fault.Invalid("idempotency key must be 1-128 bytes")
}
want, err := in.ToPolicy(token.NamespaceID)
if err != nil {
return retention.Policy{}, fault.Invalid(err.Error())
}
var out retention.Policy
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
user, err := retentionActor(ctx, tx, token)
if err != nil {
return err
}
identity, cached, err := reserveIdentity(ctx, tx, token.NamespaceID, retentionPolicyOperation, "", key, "retention_policy", token.NamespaceID, in)
if err != nil {
return err
}
if cached != nil {
return json.Unmarshal(cached, &out)
}
current, err := s.readPolicy(ctx, tx, token.NamespaceID, true)
if err != nil {
return err
}
if current.Revision != in.ExpectedRevision {
return fault.Conflict
}
if current.Revision == 0 {
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_retention_policies(namespace_id,enabled,idempotency_days,task_history_days,workflow_history_days,event_log_days,delivery_history_days,inbox_history_days,audit_log_days) VALUES (?,?,?,?,?,?,?,?,?)`, token.NamespaceID, want.Enabled, want.IdempotencyDays, want.TaskHistoryDays, want.WorkflowHistoryDays, want.EventLogDays, want.DeliveryHistoryDays, want.InboxHistoryDays, want.AuditLogDays)
var me *driver.MySQLError
if errors.As(err, &me) && me.Number == 1062 {
return fault.Conflict
}
} else {
_, err = tx.ExecContext(ctx, `UPDATE orbit_retention_policies SET revision=revision+1,enabled=?,idempotency_days=?,task_history_days=?,workflow_history_days=?,event_log_days=?,delivery_history_days=?,inbox_history_days=?,audit_log_days=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND revision=?`, want.Enabled, want.IdempotencyDays, want.TaskHistoryDays, want.WorkflowHistoryDays, want.EventLogDays, want.DeliveryHistoryDays, want.InboxHistoryDays, want.AuditLogDays, token.NamespaceID, in.ExpectedRevision)
}
if err != nil {
return err
}
out, err = s.readPolicy(ctx, tx, token.NamespaceID, false)
if err != nil {
return err
}
auditID, err := core.NewID()
if err != nil {
return err
}
old, _ := json.Marshal(current)
updated, _ := json.Marshal(out)
_, 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 (?,?,?,?,?,'retention_policy',?,?,?,?)", token.NamespaceID, auditID, identity, "console:"+string(user), retentionPolicyOperation, token.NamespaceID, in.Reason, string(old), string(updated))
if err != nil {
return err
}
return saveIdentity(ctx, tx, token.NamespaceID, identity, out)
})
return out, err
}

View File

@@ -0,0 +1,228 @@
package mysql
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"os"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
)
func TestIntegrationRetentionPolicyIsSharedAuditedAndIdempotent(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 8, 2)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
var boot BootstrapResult
app := testID(t)
if err := Bootstrap(ctx, db, BootstrapInput{
Application: string(app), Namespace: "default", Environment: "test", Queue: "admin",
ConsoleUsername: "admin-" + string(app), ConsolePassword: "a-strong-password-123",
}, func(v BootstrapResult) error {
boot = v
return nil
}); err != nil {
t.Fatal(err)
}
login, err := NewConsoleAuthStore(db).Login(ctx, "admin-"+string(app), "a-strong-password-123", "retention-test", "127.0.0.1")
if err != nil {
t.Fatal(err)
}
token, err := auth.Parse(login.Token)
if err != nil {
t.Fatal(err)
}
defaults := retention.Defaults(boot.NamespaceID)
store := NewRetentionPolicyStore(db, defaults)
got, err := store.GetRetentionPolicy(ctx, boot.NamespaceID)
if err != nil || got.Source != "default" || got.Revision != 0 || got.TaskHistoryDays != 365 {
t.Fatalf("default policy: %+v %v", got, err)
}
in := retention.UpdateInput{
ExpectedRevision: 0, Enabled: true, IdempotencyDays: 120,
TaskHistoryDays: 730, WorkflowHistoryDays: 730, EventLogDays: 365,
DeliveryHistoryDays: 120, InboxHistoryDays: 120, AuditLogDays: 2555,
Reason: "production retention policy review",
}
updated, err := store.UpdateRetentionPolicy(ctx, token, "retention-change-1", in)
if err != nil {
t.Fatal(err)
}
if updated.Source != "database" || updated.Revision != 1 || !updated.Enabled || updated.TaskHistoryDays != 730 {
t.Fatalf("updated policy: %+v", updated)
}
// A second store represents another Orbit node; it must read the same MySQL policy.
other := NewRetentionPolicyStore(db, retention.Defaults(boot.NamespaceID))
shared, err := other.GetRetentionPolicy(ctx, boot.NamespaceID)
if err != nil || shared.Revision != 1 || shared.TaskHistoryDays != 730 || !shared.Enabled {
t.Fatalf("cross-node policy read: %+v %v", shared, err)
}
replayed, err := store.UpdateRetentionPolicy(ctx, token, "retention-change-1", in)
if err != nil || replayed.Revision != updated.Revision || replayed.TaskHistoryDays != updated.TaskHistoryDays {
t.Fatalf("idempotent replay: %+v %v", replayed, err)
}
conflict := in
conflict.ExpectedRevision = 0
conflict.TaskHistoryDays = 900
if _, err := store.UpdateRetentionPolicy(ctx, token, "retention-change-2", conflict); faultCode(err) != "STATE_CONFLICT" {
t.Fatalf("stale revision accepted: %v", err)
}
var audits int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND action='retention.policy.update' AND resource_kind='retention_policy'`, boot.NamespaceID).Scan(&audits); err != nil {
t.Fatal(err)
}
if audits != 1 {
t.Fatalf("audit rows=%d want 1 after idempotent replay", audits)
}
var actor, reason string
if err := db.QueryRowContext(ctx, "SELECT actor_id,reason FROM orbit_audit_events WHERE namespace_id=? AND action='retention.policy.update'", boot.NamespaceID).Scan(&actor, &reason); err != nil {
t.Fatal(err)
}
if actor == "" || reason != in.Reason || actor == "mysql:database" {
t.Fatalf("unexpected retention audit identity: actor=%q reason=%q", actor, reason)
}
t.Logf("retention policy revision %d is shared across stores, audited once, and safe on replay", updated.Revision)
}
func faultCode(err error) string {
if err == nil {
return ""
}
return fault.Code(err)
}
func TestIntegrationRetentionDiskGrowthAndPreview(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 12, 4)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
var boot BootstrapResult
app := testID(t)
if err := Bootstrap(ctx, db, BootstrapInput{
Application: string(app), Namespace: "growth", Environment: "test", Queue: "growth",
ConsoleUsername: "growth-admin-" + string(app), ConsolePassword: "a-strong-password-123",
}, func(v BootstrapResult) error { boot = v; return nil }); err != nil {
t.Fatal(err)
}
apiToken, err := auth.Parse(boot.Token)
if err != nil {
t.Fatal(err)
}
login, err := NewConsoleAuthStore(db).Login(ctx, "growth-admin-"+string(app), "a-strong-password-123", "retention-growth-test", "127.0.0.1")
if err != nil {
t.Fatal(err)
}
consoleToken, err := auth.Parse(login.Token)
if err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(ctx, "ANALYZE TABLE pulse_tasks, orbit_events, orbit_audit_events"); err != nil {
t.Fatal(err)
}
before := retentionTableBytes(t, ctx, db)
serviceStore := service.New(NewTaskStore(db))
// 256 rows × 32 KiB is deliberately larger than one InnoDB allocation page,
// so the information_schema sample must observe physical table growth.
payload := json.RawMessage(`{"blob":"` + strings.Repeat("x", 32768) + `"}`)
ids := make([]string, 0, 256)
for i := 0; i < 256; i++ {
out, err := serviceStore.Submit(ctx, apiToken, "growth-"+string(app)+"-"+string(rune('a'+i%26))+"-"+string(testID(t)), service.SubmitInput{
QueueID: boot.QueueID, Handler: "retention.growth", HandlerVersion: "v1", PayloadVersion: "v1", Payload: payload,
TaskTimeoutMS: 60000, RecoverySafety: pulse.RecoveryReconcileOnly, Retry: pulse.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 30000},
})
if err != nil {
t.Fatal(err)
}
ids = append(ids, string(out.Task.ID))
}
for _, id := range ids {
if _, err := db.ExecContext(ctx, `UPDATE pulse_tasks SET state='FAILED',completed_at=UTC_TIMESTAMP(6)-INTERVAL 2 DAY,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=UNHEX(?)`, boot.NamespaceID, id); err != nil {
t.Fatal(err)
}
}
if _, err := db.ExecContext(ctx, "ANALYZE TABLE pulse_tasks, orbit_events, orbit_audit_events"); err != nil {
t.Fatal(err)
}
after := retentionTableBytes(t, ctx, db)
if after <= before {
t.Fatalf("retention table bytes did not grow during bounded growth sample: before=%d after=%d", before, after)
}
store := NewRetentionPolicyStore(db, retention.Defaults(boot.NamespaceID))
_, err = store.UpdateRetentionPolicy(ctx, consoleToken, "growth-retention-policy", retention.UpdateInput{
ExpectedRevision: 0, Enabled: true, IdempotencyDays: 90, TaskHistoryDays: 1,
WorkflowHistoryDays: 30, EventLogDays: 30, DeliveryHistoryDays: 30, InboxHistoryDays: 30,
AuditLogDays: 365, Reason: "bounded disk growth rehearsal",
})
if err != nil {
t.Fatal(err)
}
preview, err := store.PreviewRetention(ctx, consoleToken)
if err != nil {
t.Fatal(err)
}
var taskSample retention.CandidatePage
for _, item := range preview.Items {
if item.Kind == "tasks" {
taskSample = item
}
}
if taskSample.Count == 0 {
t.Fatalf("backdated terminal tasks were not visible as retention candidates: %+v", preview.Items)
}
snapshot, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", Limit: 1, ExpectedRevision: 1})
if err != nil {
t.Fatal(err)
}
if snapshot.FormatVersion != 1 || snapshot.Content == "" {
t.Fatalf("invalid retention snapshot envelope: %+v", snapshot)
}
sum := sha256.Sum256([]byte(snapshot.Content))
if snapshot.SHA256 != hex.EncodeToString(sum[:]) || !json.Valid([]byte(snapshot.Content)) || !strings.Contains(snapshot.Content, `"source_retained":true`) {
t.Fatalf("retention snapshot integrity/source marker failed: sha=%s content=%s", snapshot.SHA256, snapshot.Content[:min(len(snapshot.Content), 200)])
}
t.Logf("bounded retention growth sample inserted %d terminal tasks; bytes before=%d after=%d delta=%d; preview tasks=%d more=%v", len(ids), before, after, after-before, taskSample.Count, taskSample.More)
}
func retentionTableBytes(t *testing.T, ctx context.Context, db interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}) int64 {
t.Helper()
var bytes int64
if err := db.QueryRowContext(ctx, `SELECT COALESCE(SUM(data_length+index_length),0) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('pulse_tasks','orbit_events','orbit_audit_events')`).Scan(&bytes); err != nil {
t.Fatal(err)
}
return bytes
}