feat: add retention policy and stability verification
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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='命名空间历史数据保留策略,只配置窗口和开关,不自动删除';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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='命名空间历史数据保留策略,只配置窗口和开关,不自动删除';
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
377
internal/storage/mysql/retention_archive.go
Normal file
377
internal/storage/mysql/retention_archive.go
Normal 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)
|
||||
158
internal/storage/mysql/retention_policy.go
Normal file
158
internal/storage/mysql/retention_policy.go
Normal 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
|
||||
}
|
||||
228
internal/storage/mysql/retention_policy_integration_test.go
Normal file
228
internal/storage/mysql/retention_policy_integration_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user