package mysql import ( "context" "database/sql" "os" "path/filepath" "sync" "testing" "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" ) func archiveFixture(t *testing.T, payloadBytes ...int) (context.Context, *sql.DB, *RetentionPolicyStore, auth.Token, core.ID) { t.Helper() dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN") if dsn == "" { t.Skip("requires disposable MySQL") } ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) t.Cleanup(cancel) db, err := Open(ctx, dsn, 12, 4) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) if err = Migrate(ctx, db); err != nil { t.Fatal(err) } app := testID(t) username := "archive-" + string(app) var boot BootstrapResult if err = Bootstrap(ctx, db, BootstrapInput{Application: string(app), Namespace: "archive", Environment: "test", Queue: "archive", ConsoleUsername: username, ConsolePassword: "test-only-strong-password-123"}, func(b BootstrapResult) error { boot = b; return nil }); err != nil { t.Fatal(err) } login, err := NewConsoleAuthStore(db).Login(ctx, username, "test-only-strong-password-123", "archive-test", "127.0.0.1") if err != nil { t.Fatal(err) } token, err := auth.Parse(login.Token) if err != nil { t.Fatal(err) } store := NewRetentionPolicyStore(db) _, err = store.UpdateRetentionPolicy(ctx, token, "initial", retention.UpdateInput{Enabled: true, IdempotencyDays: 90, TaskHistoryDays: 1, WorkflowHistoryDays: 1, EventLogDays: 1, DeliveryHistoryDays: 1, InboxHistoryDays: 1, AuditLogDays: 1, Reason: "isolated archive test"}) if err != nil { t.Fatal(err) } // 三条过期审计作为真实归档数据,分两批读取,避免凭空伪造任务生命周期。 size := 0 if len(payloadBytes) > 0 { size = payloadBytes[0] } for i := 0; i < 3; i++ { _, err = db.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,`before`,created_at) VALUES(?,?,?,'test','fixture','archive_job',?,'source retained',JSON_OBJECT('fixture',REPEAT('x',?)),TIMESTAMPADD(DAY,-3,UTC_TIMESTAMP(6)))", boot.NamespaceID, testID(t), testID(t), testID(t), size) if err != nil { t.Fatal(err) } } return ctx, db, store, token, testID(t) } func archivePlanInput() retention.PlanInput { return retention.PlanInput{PolicyRevision: 1, Enabled: true, IntervalSeconds: 3600, BatchSize: 2, MaxBytes: 4 << 20, Reason: "enable isolated schedule"} } func mustArchiveJobs(t *testing.T, ctx context.Context, s *RetentionPolicyStore, token auth.Token) []retention.ManagedJob { t.Helper() p, err := s.ListArchiveJobs(ctx, token, "") if err != nil { t.Fatal(err) } return p.Items } func TestIntegrationArchiveSchedulerRaceResumeAndFencing(t *testing.T) { ctx, db, s, token, volume := archiveFixture(t) in := archivePlanInput() p, err := s.SaveArchivePlan(ctx, token, "plan", "audit", volume, in) if err != nil { t.Fatal(err) } replay, err := s.SaveArchivePlan(ctx, token, "plan", "audit", volume, in) if err != nil || replay.Revision != p.Revision { t.Fatal("plan replay", err) } if _, err = s.SaveArchivePlan(ctx, token, "stale", "audit", volume, in); fault.Code(err) != "STATE_CONFLICT" { t.Fatal("stale plan accepted", err) } var wg sync.WaitGroup errs := make(chan error, 8) for i := 0; i < 8; i++ { wg.Add(1) go func() { defer wg.Done(); errs <- NewRetentionPolicyStore(db).ScheduleArchive(ctx, volume) }() } wg.Wait() close(errs) for err := range errs { if err != nil { t.Fatal(err) } } jobs := mustArchiveJobs(t, ctx, s, token) if len(jobs) != 1 || jobs[0].PlanRevision != p.Revision { t.Fatalf("duplicate schedules: %+v", jobs) } claims := make(chan *retention.ManagedJob, 8) errs = make(chan error, 8) for i := 0; i < 8; i++ { wg.Add(1) go func() { defer wg.Done(); j, e := s.ClaimArchive(ctx, volume); claims <- j; errs <- e }() } wg.Wait() close(claims) close(errs) for e := range errs { if e != nil { t.Fatal(e) } } var old *retention.ManagedJob for j := range claims { if j != nil { if old != nil { t.Fatal("duplicate claim") } old = j } } if old == nil { t.Fatal("no claim") } root := filepath.Join(t.TempDir(), "volume") runtime := &retention.ArchiveRuntime{Directory: root, StorageID: volume, MaxBytes: 16 << 20, Store: s} if err = runtime.Init(); err != nil { t.Fatal(err) } dir := filepath.Join(root, string(token.NamespaceID)+"-"+string(old.ID)) spec := retention.JobSpec{NamespaceID: token.NamespaceID, Kind: "audit", PolicyRevision: 1, BatchSize: 2} first, err := retention.RunArchive(ctx, dir, spec, retention.JobOptions{JobID: old.ID, MaxBatches: 1, MaxBytes: 4 << 20}, func(ctx context.Context, id core.ID, in retention.ArchiveInput) (retention.ArchiveSnapshot, error) { return s.ExportRetentionForJob(ctx, token.NamespaceID, id, in) }) if err != nil || first.Batches != 1 || first.Roots != 2 || first.Completed { t.Fatal("first batch", first, err) } // 模拟文件已落盘但数据库确认尚未提交;租约到期后另一节点恢复同一作业。 if _, err = db.ExecContext(ctx, `UPDATE orbit_archive_jobs SET lease_until=TIMESTAMPADD(SECOND,-1,UTC_TIMESTAMP(6)) WHERE namespace_id=? AND id=?`, token.NamespaceID, old.ID); err != nil { t.Fatal(err) } newer, err := s.ClaimArchive(ctx, volume) if err != nil || newer == nil || newer.LeaseID == old.LeaseID { t.Fatal("reclaim", err) } if err = s.FinishArchive(ctx, *old, first, ""); fault.Code(err) != "STATE_CONFLICT" { t.Fatal("stale claimant advanced", err) } if err = s.FinishArchive(ctx, *newer, first, ""); err != nil { t.Fatal(err) } if err = runtime.Tick(ctx); err != nil { t.Fatal(err) } jobs = mustArchiveJobs(t, ctx, s, token) if len(jobs) != 1 || jobs[0].ID != old.ID || jobs[0].State != "SUCCEEDED" || jobs[0].Roots != 3 || jobs[0].Batches != 2 { t.Fatalf("resume result: %+v", jobs) } verified, err := retention.VerifyArchive(ctx, dir) if err != nil || verified.LastSHA256 != jobs[0].LastSHA256 || !verified.Completed { t.Fatal("offline mismatch", err) } if err = runtime.Tick(ctx); err != nil { t.Fatal(err) } if len(mustArchiveJobs(t, ctx, s, token)) != 1 { t.Fatal("replayed due slot") } var sources, fks int if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND action='fixture'`, token.NamespaceID).Scan(&sources); err != nil || sources != 3 { t.Fatal("source deleted", err) } if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&fks); err != nil || fks != 0 { t.Fatal("foreign keys introduced", err) } t.Log("8 schedulers -> 1 job; 8 claimants -> 1 lease; durable file prefix survives missing DB confirmation; old lease rejected; 3 source rows retained") } func TestIntegrationArchiveFailureRetryPolicyAndScope(t *testing.T) { ctx, db, s, token, volume := archiveFixture(t) in := archivePlanInput() if _, err := s.SaveArchivePlan(ctx, token, "plan", "audit", volume, in); err != nil { t.Fatal(err) } runtime := &retention.ArchiveRuntime{Directory: filepath.Join(t.TempDir(), "volume"), StorageID: volume, MaxBytes: 16 << 20, Store: s} if err := runtime.Init(); err != nil { t.Fatal(err) } if err := runtime.Tick(ctx); err != nil { t.Fatal(err) } j := mustArchiveJobs(t, ctx, s, token)[0] if j.State != "PENDING" || j.Batches != 1 { t.Fatalf("first chunk %+v", j) } dir := filepath.Join(runtime.Directory, string(token.NamespaceID)+"-"+string(j.ID)) file := filepath.Join(dir, "batch-000001.json") saved, err := os.ReadFile(file) if err != nil { t.Fatal(err) } if err = os.WriteFile(file, []byte("broken"), 0600); err != nil { t.Fatal(err) } if err = runtime.Tick(ctx); err != nil { t.Fatal(err) } failed := mustArchiveJobs(t, ctx, s, token)[0] if failed.State != "FAILED" || failed.ErrorCode != "FILE_CHECK_FAILED" || failed.LastSHA256 != j.LastSHA256 || failed.Batches != 1 { t.Fatalf("failed checkpoint %+v", failed) } // 失败占位阻止到期计划另建作业。 if _, err = db.ExecContext(ctx, `UPDATE orbit_archive_plans SET next_run_at=TIMESTAMPADD(DAY,-2,UTC_TIMESTAMP(6)) WHERE namespace_id=?`, token.NamespaceID); err != nil { t.Fatal(err) } if err = runtime.Tick(ctx); err != nil { t.Fatal(err) } if len(mustArchiveJobs(t, ctx, s, token)) != 1 { t.Fatal("failure spawned another job") } action := retention.JobActionInput{ExpectedRevision: failed.Revision, Action: "retry", MaxBytes: failed.MaxBytes, Reason: "restored exact original archive file"} if err = os.WriteFile(file, saved, 0600); err != nil { t.Fatal(err) } retried, err := s.ActArchiveJob(ctx, token, j.ID, "retry-original", action) if err != nil || retried.ID != j.ID || retried.State != "PENDING" { t.Fatal("retry", err) } if _, err = s.ActArchiveJob(ctx, token, j.ID, "retry-original", action); err != nil { t.Fatal("retry replay", err) } if _, err = s.ActArchiveJob(ctx, token, j.ID, "retry-stale", action); fault.Code(err) != "STATE_CONFLICT" { t.Fatal("stale retry", err) } // 改策略不允许旧作业静默采用新保留窗口。 _, err = s.UpdateRetentionPolicy(ctx, token, "changed-policy", retention.UpdateInput{ExpectedRevision: 1, Enabled: true, IdempotencyDays: 90, TaskHistoryDays: 2, WorkflowHistoryDays: 2, EventLogDays: 2, DeliveryHistoryDays: 2, InboxHistoryDays: 2, AuditLogDays: 2, Reason: "new reviewed retention policy"}) if err != nil { t.Fatal(err) } if err = runtime.Tick(ctx); err != nil { t.Fatal(err) } failed = mustArchiveJobs(t, ctx, s, token)[0] if failed.State != "FAILED" || failed.ErrorCode != "POLICY_CHANGED" { t.Fatalf("changed policy %+v", failed) } action = retention.JobActionInput{ExpectedRevision: failed.Revision, Action: "abandon", MaxBytes: failed.MaxBytes, Reason: "close old version; preserve files"} closed, err := s.ActArchiveJob(ctx, token, j.ID, "close", action) if err != nil || closed.State != "ABANDONED" { t.Fatal("close", err) } if err = runtime.Tick(ctx); err != nil { t.Fatal(err) } if len(mustArchiveJobs(t, ctx, s, token)) != 1 { t.Fatal("stale plan triggered") } // 无会话和异命名空间不能读取或处理作业。 other := token other.NamespaceID = testID(t) if _, err = s.ListArchiveJobs(ctx, other, ""); err == nil { t.Fatal("cross namespace read") } if _, err = s.ActArchiveJob(ctx, other, j.ID, "cross", action); err == nil { t.Fatal("cross namespace action") } // 更新计划策略后,过期的多次周期只生成一个新作业。 in.ExpectedRevision = 1 in.PolicyRevision = 2 if _, err = s.SaveArchivePlan(ctx, token, "plan-new-policy", "audit", volume, in); err != nil { t.Fatal(err) } if err = runtime.Tick(ctx); err != nil { t.Fatal(err) } jobs := mustArchiveJobs(t, ctx, s, token) if len(jobs) != 2 || jobs[0].PolicyRevision != 2 { t.Fatalf("new policy job %+v", jobs) } // 暂停计划不影响已有作业结清,但不再生成后续作业。 in.ExpectedRevision = 2 in.Enabled = false if _, err = s.SaveArchivePlan(ctx, token, "pause-plan", "audit", volume, in); err != nil { t.Fatal(err) } if err = runtime.Tick(ctx); err != nil { t.Fatal(err) } jobs = mustArchiveJobs(t, ctx, s, token) if len(jobs) != 2 || jobs[0].State != "SUCCEEDED" { t.Fatalf("pause blocked in-flight %+v", jobs) } var retries int if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND action='retention.job.retry'`, token.NamespaceID).Scan(&retries); err != nil || retries != 1 { t.Fatal("retry audit duplicated", err) } t.Log("corrupt file fails closed; restored file retries same identity exactly once; policy change blocks old job; audited close releases slot; disabled plan lets current job settle") } func TestIntegrationArchiveBudgetsResumeAndPagination(t *testing.T) { ctx, db, s, token, volume := archiveFixture(t, 600000) in := archivePlanInput() in.MaxBytes = 1 << 20 if _, err := s.SaveArchivePlan(ctx, token, "plan", "audit", volume, in); err != nil { t.Fatal(err) } runtime := &retention.ArchiveRuntime{Directory: filepath.Join(t.TempDir(), "volume"), StorageID: volume, MaxBytes: 8 << 20, Store: s} if err := runtime.Init(); err != nil { t.Fatal(err) } if err := runtime.Tick(ctx); err != nil { t.Fatal(err) } failed := mustArchiveJobs(t, ctx, s, token)[0] if failed.State != "FAILED" || failed.ErrorCode != "STORAGE_LIMIT" { t.Fatalf("budget was ignored %+v", failed) } _, err := s.ActArchiveJob(ctx, token, failed.ID, "raise-budget", retention.JobActionInput{ExpectedRevision: failed.Revision, Action: "retry", MaxBytes: 4 << 20, Reason: "reviewed file budget"}) if err != nil { t.Fatal(err) } for i := 0; i < 2; i++ { if err = runtime.Tick(ctx); err != nil { t.Fatal(err) } } complete := mustArchiveJobs(t, ctx, s, token)[0] if complete.ID != failed.ID || complete.State != "SUCCEEDED" || complete.Roots != 3 || complete.Bytes <= 1<<20 || complete.Bytes > 4<<20 { t.Fatalf("budget retry %+v", complete) } // 全卷预算独立于单作业预算:即使单作业允许4MiB,也不能突破剩余卷容量。 if _, err = db.ExecContext(ctx, `UPDATE orbit_archive_plans SET next_run_at=TIMESTAMPADD(DAY,-1,UTC_TIMESTAMP(6)) WHERE namespace_id=?`, token.NamespaceID); err != nil { t.Fatal(err) } runtime.MaxBytes = complete.Bytes + 100 if err = runtime.Tick(ctx); err != nil { t.Fatal(err) } jobs := mustArchiveJobs(t, ctx, s, token) if len(jobs) != 2 || jobs[0].State != "FAILED" || jobs[0].ErrorCode != "STORAGE_LIMIT" || jobs[0].Bytes != 0 { t.Fatalf("volume budget %+v", jobs) } // 分页使用真实时间+ID顺序,数量限制由SQL完成;无效/异命名空间位置拒绝。 for i := 0; i < 24; i++ { _, err = db.ExecContext(ctx, `INSERT INTO orbit_archive_jobs(namespace_id,id,kind,plan_revision,policy_revision,storage_id,batch_size,max_bytes,state,created_at) VALUES(?,?,'audit',1,1,?,2,1048576,'ABANDONED',TIMESTAMPADD(DAY,-2,UTC_TIMESTAMP(6)))`, token.NamespaceID, testID(t), volume) if err != nil { t.Fatal(err) } } first, err := s.ListArchiveJobs(ctx, token, "") if err != nil || len(first.Items) != 20 || first.NextBefore == "" { t.Fatal("first page", err) } second, err := s.ListArchiveJobs(ctx, token, first.NextBefore) if err != nil || len(second.Items) != 6 || second.NextBefore != "" { t.Fatal("second page", err) } seen := map[core.ID]bool{} for _, j := range append(first.Items, second.Items...) { if seen[j.ID] { t.Fatal("duplicate page item") } seen[j.ID] = true } if _, err = s.ListArchiveJobs(ctx, token, string(testID(t))); fault.Code(err) != "NOT_FOUND" { t.Fatal("unknown page position", err) } t.Log("single-job byte budget stops and resumes same ID; volume budget blocks extra output; 26 jobs paginate 20+6 without duplicates") }