feat: schedule persistent archive jobs with console management

This commit is contained in:
2026-09-23 11:29:32 +08:00
parent 6eb429e21f
commit 0140c78ea7
32 changed files with 2077 additions and 20 deletions

View File

@@ -0,0 +1,380 @@
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")
}