feat: add retention policy and stability verification
This commit is contained in:
43
internal/retention/archive.go
Normal file
43
internal/retention/archive.go
Normal 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)
|
||||
}
|
||||
192
internal/retention/policy.go
Normal file
192
internal/retention/policy.go
Normal 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
|
||||
}
|
||||
65
internal/retention/policy_test.go
Normal file
65
internal/retention/policy_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user