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

@@ -0,0 +1,43 @@
package retention
import (
"context"
"time"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
// ArchiveInput 选择一类历史快照;每批最多100条根记录,游标绑定类别和策略版本。
type ArchiveInput struct {
Kind string `json:"kind" required:"true"` // Kind tasks、workflows、events、audit、deliveries、inbox 或 idempotency。
Limit int `json:"limit"` // Limit 根记录数量,默认20,最大100。
Cursor string `json:"cursor"` // Cursor 上一批返回的继续位置,空表示从最早记录开始。
ExpectedRevision uint64 `json:"expected_revision,string" required:"true"` // ExpectedRevision 防止使用页面未确认的保留窗口导出。
}
// ArchiveSnapshot 是一个只读归档批次;SourceRetained始终为true,禁止把导出当作数据库清理。
type ArchiveSnapshot struct {
FormatVersion int `json:"format_version"` // FormatVersion 归档信封格式版本。
SHA256 string `json:"sha256"` // SHA256 对Content原始UTF-8字节的摘要,校验传输和文件完整性。
Content string `json:"content"` // Content 是原始 JSON 文本;下载时按 UTF-8 原样保存再核验 SHA256,不重新序列化。
}
// CandidatePage 描述按策略筛选的一批根记录,不宣称它们可以删除。
type CandidatePage struct {
Kind string `json:"kind"` // Kind 历史类型。
Cutoff time.Time `json:"cutoff"` // Cutoff 当前策略的UTC排除边界。
Count int `json:"count"` // Count 本批候选数量,不是全库数量。
More bool `json:"more"` // More 后面还有候选根记录。
}
// Preview 返回按各类保留窗口读取的有限候选数量,不加载业务正文。
type Preview struct {
Policy Policy `json:"policy"` // Policy 实际读取的版本和窗口。
SampleLimit int `json:"sample_limit"` // SampleLimit 每类候选的取样上限。
Items []CandidatePage `json:"items"` // Items 固定类别的候选样本。
}
type ArchiveStore interface {
PreviewRetention(context.Context, auth.Token) (Preview, error)
ExportRetention(context.Context, auth.Token, ArchiveInput) (ArchiveSnapshot, error)
}

View File

@@ -0,0 +1,192 @@
// Package retention 定义 Orbit 历史数据保留策略的通用模型。
//
// 策略只描述候选数据的最短保留窗口,不直接删除任务、事件或审计记录。
// 归档执行器必须在独立流程中先完成核查、备份和归档证明,再按策略处理。
package retention
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
const (
// DefaultIdempotencyDays 是请求幂等记录的默认保留天数。
DefaultIdempotencyDays uint32 = 90
// DefaultTaskHistoryDays 是终态任务历史的默认保留天数。
DefaultTaskHistoryDays uint32 = 365
// DefaultWorkflowHistoryDays 是终态流程历史的默认保留天数。
DefaultWorkflowHistoryDays uint32 = 365
// DefaultEventLogDays 是状态事件和结果事件的默认保留天数。
DefaultEventLogDays uint32 = 365
// DefaultDeliveryHistoryDays 是已确认交付历史的默认保留天数。
DefaultDeliveryHistoryDays uint32 = 90
// DefaultInboxHistoryDays 是已处理流程 Inbox 历史的默认保留天数。
DefaultInboxHistoryDays uint32 = 90
// DefaultAuditLogDays 是管理审计的默认保留天数,约七年。
DefaultAuditLogDays uint32 = 2555
minRetentionDays uint32 = 1
maxRetentionDays uint32 = 3650
)
// Policy 是按命名空间保存的历史数据保留策略。
// Enabled 默认关闭;即使打开,也允许只读归档导出使用满足窗口且已核验的数据。
// 当前版本不会因为配置变更自动删除任何数据。
type Policy struct {
// NamespaceID 是策略所属命名空间,不能由控制台请求替换。
NamespaceID core.ID `json:"namespace_id"`
// Revision 是乐观并发版本;默认策略未落库时为0,首次保存后从1开始。
Revision uint64 `json:"revision,string"`
// Enabled 表示是否允许归档执行器使用该策略;默认关闭以避免误清理。
Enabled bool `json:"enabled"`
// IdempotencyDays 是请求幂等记录至少保留的天数。
IdempotencyDays uint32 `json:"idempotency_days"`
// TaskHistoryDays 是终态任务及执行证据至少保留的天数。
TaskHistoryDays uint32 `json:"task_history_days"`
// WorkflowHistoryDays 是终态流程、步骤、等待和流程事件至少保留的天数。
WorkflowHistoryDays uint32 `json:"workflow_history_days"`
// EventLogDays 是状态事件和结果事件至少保留的天数。
EventLogDays uint32 `json:"event_log_days"`
// DeliveryHistoryDays 是已确认结果交付至少保留的天数;未确认交付不得按窗口清理。
DeliveryHistoryDays uint32 `json:"delivery_history_days"`
// InboxHistoryDays 是已处理流程 Inbox 事件至少保留的天数;未处理记录不得清理。
InboxHistoryDays uint32 `json:"inbox_history_days"`
// AuditLogDays 是管理审计至少保留的天数;审计记录不参与普通清理。
AuditLogDays uint32 `json:"audit_log_days"`
// UpdatedAt 是策略最后修改时间,统一使用 UTC。
UpdatedAt time.Time `json:"updated_at"`
// Source 表示策略来自默认值还是数据库配置,便于控制台明确当前状态。
Source string `json:"source"`
}
// UpdateInput 是控制台保存策略所需的完整替换输入。
// 所有窗口必须显式提交,避免遗漏字段被解释成缩短保留期。
type UpdateInput struct {
// ExpectedRevision 是页面读取到的版本;首次保存必须为0。
ExpectedRevision uint64 `json:"expected_revision,string" required:"true"`
// Enabled 是否允许归档执行器读取本策略。
Enabled bool `json:"enabled" required:"true"`
// IdempotencyDays 请求幂等记录保留天数。
IdempotencyDays uint32 `json:"idempotency_days" required:"true"`
// TaskHistoryDays 终态任务历史保留天数。
TaskHistoryDays uint32 `json:"task_history_days" required:"true"`
// WorkflowHistoryDays 终态流程历史保留天数。
WorkflowHistoryDays uint32 `json:"workflow_history_days" required:"true"`
// EventLogDays 状态和结果事件保留天数。
EventLogDays uint32 `json:"event_log_days" required:"true"`
// DeliveryHistoryDays 已确认交付历史保留天数。
DeliveryHistoryDays uint32 `json:"delivery_history_days" required:"true"`
// InboxHistoryDays 已处理 Inbox 历史保留天数。
InboxHistoryDays uint32 `json:"inbox_history_days" required:"true"`
// AuditLogDays 管理审计保留天数。
AuditLogDays uint32 `json:"audit_log_days" required:"true"`
// Reason 是本次策略变更原因,会进入管理审计。
Reason string `json:"reason" required:"true"`
}
// Store 是控制台和归档执行器共享的最小策略存储边界。
// 实现必须保证 Update 的版本核对、幂等和审计与策略写入处于同一事务。
type Store interface {
GetRetentionPolicy(context.Context, core.ID) (Policy, error)
UpdateRetentionPolicy(context.Context, auth.Token, string, UpdateInput) (Policy, error)
}
// Defaults 返回不会触发任何归档执行的安全默认策略。
func Defaults(namespace core.ID) Policy {
return Policy{
NamespaceID: namespace,
Enabled: false,
IdempotencyDays: DefaultIdempotencyDays,
TaskHistoryDays: DefaultTaskHistoryDays,
WorkflowHistoryDays: DefaultWorkflowHistoryDays,
EventLogDays: DefaultEventLogDays,
DeliveryHistoryDays: DefaultDeliveryHistoryDays,
InboxHistoryDays: DefaultInboxHistoryDays,
AuditLogDays: DefaultAuditLogDays,
Source: "default",
}
}
// Validate 检查策略身份、窗口和审计原因。
func (p Policy) Validate() error {
if p.NamespaceID.Validate() != nil {
return errors.New("retention namespace is invalid")
}
for name, days := range map[string]uint32{
"idempotency_days": p.IdempotencyDays,
"task_history_days": p.TaskHistoryDays,
"workflow_history_days": p.WorkflowHistoryDays,
"event_log_days": p.EventLogDays,
"delivery_history_days": p.DeliveryHistoryDays,
"inbox_history_days": p.InboxHistoryDays,
"audit_log_days": p.AuditLogDays,
} {
if days < minRetentionDays || days > maxRetentionDays {
return fmt.Errorf("%s must be between %d and %d days", name, minRetentionDays, maxRetentionDays)
}
}
if p.IdempotencyDays < DefaultIdempotencyDays {
return errors.New("idempotency_days cannot be less than 90 days")
}
return nil
}
// ToPolicy 将完整输入转为待保存策略,版本由事务内分配。
func (in UpdateInput) ToPolicy(namespace core.ID) (Policy, error) {
if strings.TrimSpace(in.Reason) == "" || !utf8.ValidString(in.Reason) || len(in.Reason) > 1024 || strings.ContainsAny(in.Reason, "\r\n") {
return Policy{}, errors.New("retention reason must be 1-1024 UTF-8 bytes without newlines")
}
p := Policy{
NamespaceID: namespace,
Enabled: in.Enabled,
IdempotencyDays: in.IdempotencyDays,
TaskHistoryDays: in.TaskHistoryDays,
WorkflowHistoryDays: in.WorkflowHistoryDays,
EventLogDays: in.EventLogDays,
DeliveryHistoryDays: in.DeliveryHistoryDays,
InboxHistoryDays: in.InboxHistoryDays,
AuditLogDays: in.AuditLogDays,
}
if err := p.Validate(); err != nil {
return Policy{}, err
}
return p, nil
}
// FromEnv 读取部署默认值;数据库策略保存后优先于部署默认值。
// 所有节点应注入同一组默认值,保证尚未保存策略的展示一致。
func FromEnv(get func(string) string) (Policy, error) {
p := Defaults("")
for _, f := range []struct {
name string
dst *uint32
}{
{"ORBIT_RETENTION_IDEMPOTENCY_DAYS", &p.IdempotencyDays},
{"ORBIT_RETENTION_TASK_DAYS", &p.TaskHistoryDays},
{"ORBIT_RETENTION_WORKFLOW_DAYS", &p.WorkflowHistoryDays},
{"ORBIT_RETENTION_EVENT_DAYS", &p.EventLogDays},
{"ORBIT_RETENTION_DELIVERY_DAYS", &p.DeliveryHistoryDays},
{"ORBIT_RETENTION_INBOX_DAYS", &p.InboxHistoryDays},
{"ORBIT_RETENTION_AUDIT_DAYS", &p.AuditLogDays},
} {
if raw := get(f.name); raw != "" {
n, err := strconv.ParseUint(raw, 10, 32)
if err != nil || n < 1 || n > 3650 {
return Policy{}, fmt.Errorf("%s must be 1-3650 days", f.name)
}
*f.dst = uint32(n)
}
}
if p.IdempotencyDays < 90 {
return Policy{}, errors.New("ORBIT_RETENTION_IDEMPOTENCY_DAYS cannot be less than 90")
}
return p, nil
}

View File

@@ -0,0 +1,65 @@
package retention
import (
"testing"
core "git.yuntuo.hk/k2safe/orbit/model"
)
func TestDefaultsAndValidation(t *testing.T) {
ns, err := core.NewID()
if err != nil {
t.Fatal(err)
}
p := Defaults(ns)
if p.Enabled || p.Source != "default" || p.IdempotencyDays != 90 || p.TaskHistoryDays != 365 || p.AuditLogDays != 2555 {
t.Fatalf("unexpected defaults: %+v", p)
}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
p.IdempotencyDays = 89
if err := p.Validate(); err == nil {
t.Fatal("accepted an idempotency window shorter than the safety minimum")
}
}
func TestUpdateInputRequiresReasonAndKeepsSafetyMinimum(t *testing.T) {
ns, _ := core.NewID()
in := UpdateInput{
IdempotencyDays: 90, TaskHistoryDays: 30, WorkflowHistoryDays: 30,
EventLogDays: 30, DeliveryHistoryDays: 30, InboxHistoryDays: 30,
AuditLogDays: 365, Reason: "quarterly retention review",
}
if _, err := in.ToPolicy(ns); err != nil {
t.Fatal(err)
}
in.Reason = ""
if _, err := in.ToPolicy(ns); err == nil {
t.Fatal("accepted an empty audit reason")
}
in.Reason = "shorten"
in.IdempotencyDays = 89
if _, err := in.ToPolicy(ns); err == nil {
t.Fatal("accepted an unsafe idempotency window")
}
}
func TestFromEnvOverridesOnlyDeploymentDefaults(t *testing.T) {
values := map[string]string{
"ORBIT_RETENTION_IDEMPOTENCY_DAYS": "120",
"ORBIT_RETENTION_TASK_DAYS": "180",
"ORBIT_RETENTION_AUDIT_DAYS": "730",
}
p, err := FromEnv(func(k string) string { return values[k] })
if err != nil {
t.Fatal(err)
}
if p.IdempotencyDays != 120 || p.TaskHistoryDays != 180 || p.AuditLogDays != 730 || p.EventLogDays != DefaultEventLogDays {
t.Fatalf("environment defaults not applied: %+v", p)
}
values["ORBIT_RETENTION_IDEMPOTENCY_DAYS"] = "89"
if _, err := FromEnv(func(k string) string { return values[k] }); err == nil {
t.Fatal("accepted unsafe environment idempotency window")
}
}