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

@@ -94,7 +94,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
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(`DROP TABLE orbit_archive_jobs,orbit_archive_plans,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)

View File

@@ -0,0 +1,441 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"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 archivePlanColumns = `namespace_id,kind,revision,enabled,policy_revision,storage_id,interval_seconds,batch_size,max_bytes,next_run_at,active_job_id,updated_at`
const archiveJobColumns = `namespace_id,id,kind,plan_revision,policy_revision,storage_id,batch_size,max_bytes,state,revision,attempts,batches,roots,bytes,last_sha256,error_code,lease_id,lease_until,created_at,updated_at`
type archiveScanner interface{ Scan(...any) error }
func scanArchivePlan(row archiveScanner) (p retention.ArchivePlan, err error) {
err = row.Scan(&p.NamespaceID, &p.Kind, &p.Revision, &p.Enabled, &p.PolicyRevision, &p.StorageID, &p.IntervalSeconds, &p.BatchSize, &p.MaxBytes, &p.NextRunAt, &p.ActiveJobID, &p.UpdatedAt)
return
}
func scanArchiveJob(row archiveScanner) (j retention.ManagedJob, err error) {
var lease *core.ID
err = row.Scan(&j.NamespaceID, &j.ID, &j.Kind, &j.PlanRevision, &j.PolicyRevision, &j.StorageID, &j.BatchSize, &j.MaxBytes, &j.State, &j.Revision, &j.Attempts, &j.Batches, &j.Roots, &j.Bytes, &j.LastSHA256, &j.ErrorCode, &lease, &j.LeaseUntil, &j.CreatedAt, &j.UpdatedAt)
if lease != nil {
j.LeaseID = *lease
}
return
}
func readArchivePlan(ctx context.Context, tx *sql.Tx, ns core.ID, kind string) (retention.ArchivePlan, error) {
p, err := scanArchivePlan(tx.QueryRowContext(ctx, `SELECT `+archivePlanColumns+` FROM orbit_archive_plans WHERE namespace_id=? AND kind=? FOR UPDATE`, ns, kind))
if errors.Is(err, sql.ErrNoRows) {
return retention.ArchivePlan{NamespaceID: ns, Kind: kind, IntervalSeconds: 86400, BatchSize: 20, MaxBytes: 256 << 20}, nil
}
return p, err
}
func readArchiveJob(ctx context.Context, tx *sql.Tx, ns, id core.ID) (retention.ManagedJob, error) {
j, err := scanArchiveJob(tx.QueryRowContext(ctx, `SELECT `+archiveJobColumns+` FROM orbit_archive_jobs WHERE namespace_id=? AND id=? FOR UPDATE`, ns, id))
if errors.Is(err, sql.ErrNoRows) {
err = fault.NotFound
}
return j, err
}
func archiveAudit(ctx context.Context, tx *sql.Tx, ns, resource, op core.ID, actor, action, kind, reason string, before, after any) error {
id, err := core.NewID()
if err != nil {
return err
}
b, err := json.Marshal(before)
if err != nil {
return err
}
a, err := json.Marshal(after)
if err != nil {
return err
}
_, 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 (?,?,?,?,?,?,?,?,?,?)", ns, id, op, actor, action, kind, resource, reason, string(b), string(a))
return err
}
func archiveKey(key string) error {
if strings.TrimSpace(key) == "" || len(key) > 128 {
return fault.Invalid("idempotency key must be 1-128 bytes")
}
return nil
}
func (s *RetentionPolicyStore) ListArchivePlans(ctx context.Context, t auth.Token) (out []retention.ArchivePlan, err error) {
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
if _, err := retentionActor(ctx, tx, t); err != nil {
return err
}
rows, err := tx.QueryContext(ctx, `SELECT `+archivePlanColumns+` FROM orbit_archive_plans WHERE namespace_id=? ORDER BY kind`, t.NamespaceID)
if err != nil {
return err
}
defer rows.Close()
found := map[string]retention.ArchivePlan{}
for rows.Next() {
p, err := scanArchivePlan(rows)
if err != nil {
return err
}
found[p.Kind] = p
}
if err := rows.Err(); err != nil {
return err
}
out = []retention.ArchivePlan{}
for _, kind := range retention.ArchiveKinds {
p, ok := found[kind]
if !ok {
p = retention.ArchivePlan{NamespaceID: t.NamespaceID, Kind: kind, IntervalSeconds: 86400, BatchSize: 20, MaxBytes: 256 << 20}
}
out = append(out, p)
}
return nil
})
return
}
func (s *RetentionPolicyStore) SaveArchivePlan(ctx context.Context, t auth.Token, key, kind string, storage core.ID, in retention.PlanInput) (out retention.ArchivePlan, err error) {
if err = in.Validate(t.NamespaceID, kind); err != nil {
return out, fault.Invalid(err.Error())
}
if err = archiveKey(key); err != nil {
return
}
// 卷身份属于幂等请求摘要;跨不同卷的节点不能把同一保存解释成不同目标。
input := struct {
Kind string
Storage core.ID
Input retention.PlanInput
}{kind, storage, in}
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
user, err := retentionActor(ctx, tx, t)
if err != nil {
return err
}
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "retention.plan.update", kind, key, "archive_plan", t.NamespaceID, input)
if err != nil {
return err
}
if cached != nil {
return json.Unmarshal(cached, &out)
}
policy, err := s.readPolicy(ctx, tx, t.NamespaceID, true)
if err != nil {
return err
}
if policy.Revision != in.PolicyRevision || (in.Enabled && !policy.Enabled) {
return fault.Conflict
}
before, err := readArchivePlan(ctx, tx, t.NamespaceID, kind)
if err != nil {
return err
}
if before.Revision != in.ExpectedRevision {
return fault.Conflict
}
target := storage
if target == "" && !in.Enabled {
target = before.StorageID
}
if target.Validate() != nil {
return fault.Invalid("archive storage is not configured")
}
if before.ActiveJobID != nil && before.StorageID != target {
return fault.Conflict
}
if before.Revision == 0 {
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_archive_plans(namespace_id,kind,enabled,policy_revision,storage_id,interval_seconds,batch_size,max_bytes,next_run_at) VALUES(?,?,?,?,?,?,?,?,UTC_TIMESTAMP(6))`, t.NamespaceID, kind, in.Enabled, in.PolicyRevision, target, in.IntervalSeconds, in.BatchSize, in.MaxBytes)
} else {
_, err = tx.ExecContext(ctx, `UPDATE orbit_archive_plans SET revision=revision+1,next_run_at=IF(enabled=FALSE AND ?=TRUE,UTC_TIMESTAMP(6),next_run_at),enabled=?,policy_revision=?,storage_id=?,interval_seconds=?,batch_size=?,max_bytes=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND kind=?`, in.Enabled, in.Enabled, in.PolicyRevision, target, in.IntervalSeconds, in.BatchSize, in.MaxBytes, t.NamespaceID, kind)
}
if err != nil {
return err
}
out, err = readArchivePlan(ctx, tx, t.NamespaceID, kind)
if err != nil {
return err
}
if err = archiveAudit(ctx, tx, t.NamespaceID, t.NamespaceID, identity, "console:"+string(user), "retention.plan.update", "archive_plan", in.Reason, before, out); err != nil {
return err
}
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
})
return
}
func (s *RetentionPolicyStore) ListArchiveJobs(ctx context.Context, t auth.Token, before string) (out retention.JobPage, err error) {
if before != "" && core.ID(before).Validate() != nil {
return out, fault.Invalid("invalid archive page position")
}
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
if _, err := retentionActor(ctx, tx, t); err != nil {
return err
}
q := `SELECT ` + archiveJobColumns + ` FROM orbit_archive_jobs WHERE namespace_id=?`
args := []any{t.NamespaceID}
if before != "" {
var at time.Time
if err := tx.QueryRowContext(ctx, `SELECT created_at FROM orbit_archive_jobs WHERE namespace_id=? AND id=?`, t.NamespaceID, core.ID(before)).Scan(&at); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fault.NotFound
}
return err
}
q += ` AND (created_at<? OR (created_at=? AND id<?))`
args = append(args, at, at, core.ID(before))
}
rows, err := tx.QueryContext(ctx, q+` ORDER BY created_at DESC,id DESC LIMIT 21`, args...)
if err != nil {
return err
}
defer rows.Close()
out = retention.JobPage{Items: []retention.ManagedJob{}}
for rows.Next() {
j, err := scanArchiveJob(rows)
if err != nil {
return err
}
out.Items = append(out.Items, j)
}
if err := rows.Err(); err != nil {
return err
}
if len(out.Items) > 20 {
out.Items = out.Items[:20]
out.NextBefore = string(out.Items[19].ID)
}
return nil
})
return
}
func (s *RetentionPolicyStore) ActArchiveJob(ctx context.Context, t auth.Token, id core.ID, key string, in retention.JobActionInput) (out retention.ManagedJob, err error) {
if id.Validate() != nil || (in.Action != "retry" && in.Action != "abandon") || !retention.ValidReason(in.Reason) || in.MaxBytes < 1<<20 || in.MaxBytes > 1<<30 {
return out, fault.Invalid("invalid archive action, budget or reason")
}
if err = archiveKey(key); err != nil {
return
}
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
user, err := retentionActor(ctx, tx, t)
if err != nil {
return err
}
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "retention.job.action", string(id), key, "archive_job", id, in)
if err != nil {
return err
}
if cached != nil {
return json.Unmarshal(cached, &out)
}
var kind string
if err := tx.QueryRowContext(ctx, `SELECT kind FROM orbit_archive_jobs WHERE namespace_id=? AND id=?`, t.NamespaceID, id).Scan(&kind); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fault.NotFound
}
return err
}
p, err := readArchivePlan(ctx, tx, t.NamespaceID, kind)
if err != nil {
return err
}
j, err := readArchiveJob(ctx, tx, t.NamespaceID, id)
if err != nil {
return err
}
if p.ActiveJobID == nil || *p.ActiveJobID != id || j.State != "FAILED" || j.Revision != in.ExpectedRevision || in.MaxBytes < j.MaxBytes {
return fault.Conflict
}
next := "PENDING"
if in.Action == "abandon" {
next = "ABANDONED"
if in.MaxBytes != j.MaxBytes {
return fault.Conflict
}
}
_, err = tx.ExecContext(ctx, `UPDATE orbit_archive_jobs SET state=?,revision=revision+1,max_bytes=?,error_code='',updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, next, in.MaxBytes, t.NamespaceID, id)
if err != nil {
return err
}
if next == "ABANDONED" {
if _, err = tx.ExecContext(ctx, `UPDATE orbit_archive_plans SET active_job_id=NULL WHERE namespace_id=? AND kind=? AND active_job_id=?`, t.NamespaceID, kind, id); err != nil {
return err
}
}
out, err = readArchiveJob(ctx, tx, t.NamespaceID, id)
if err != nil {
return err
}
if err = archiveAudit(ctx, tx, t.NamespaceID, id, identity, "console:"+string(user), "retention.job."+in.Action, "archive_job", in.Reason, j, out); err != nil {
return err
}
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
})
return
}
// ScheduleArchive 在数据库时钟下合并错过的时间槽;策略、计划和新作业同事务核验。
func (s *RetentionPolicyStore) ScheduleArchive(ctx context.Context, storage core.ID) error {
if storage.Validate() != nil {
return fault.Invalid("invalid storage id")
}
rows, err := s.db.QueryContext(ctx, `SELECT p.namespace_id,p.kind FROM orbit_archive_plans p JOIN orbit_retention_policies r ON r.namespace_id=p.namespace_id AND r.revision=p.policy_revision AND r.enabled=TRUE JOIN orbit_namespaces n ON n.id=p.namespace_id AND n.enabled=TRUE JOIN orbit_applications a ON a.id=n.application_id AND a.enabled=TRUE WHERE p.storage_id=? AND p.enabled=TRUE AND p.active_job_id IS NULL AND p.next_run_at<=UTC_TIMESTAMP(6) ORDER BY p.next_run_at LIMIT 16`, storage)
if err != nil {
return err
}
type candidate struct {
ns core.ID
kind string
}
var candidates []candidate
for rows.Next() {
var c candidate
if err = rows.Scan(&c.ns, &c.kind); err != nil {
rows.Close()
return err
}
candidates = append(candidates, c)
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
for _, c := range candidates {
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
var enabled bool
if err := tx.QueryRowContext(ctx, `SELECT a.enabled AND n.enabled FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=? FOR SHARE`, c.ns).Scan(&enabled); err != nil {
return err
}
if !enabled {
return nil
}
policy, err := s.readPolicy(ctx, tx, c.ns, true)
if err != nil {
return err
}
p, err := readArchivePlan(ctx, tx, c.ns, c.kind)
if err != nil {
return err
}
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
if !p.Enabled || p.ActiveJobID != nil || p.StorageID != storage || p.NextRunAt == nil || p.NextRunAt.After(now) || !policy.Enabled || policy.Revision != p.PolicyRevision {
return nil
}
id, err := core.NewID()
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_archive_jobs(namespace_id,id,kind,plan_revision,policy_revision,storage_id,batch_size,max_bytes) VALUES(?,?,?,?,?,?,?,?)`, c.ns, id, c.kind, p.Revision, p.PolicyRevision, storage, p.BatchSize, p.MaxBytes)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `UPDATE orbit_archive_plans SET active_job_id=?,next_run_at=? WHERE namespace_id=? AND kind=?`, id, now.Add(time.Duration(p.IntervalSeconds)*time.Second), c.ns, c.kind)
if err != nil {
return err
}
j, err := readArchiveJob(ctx, tx, c.ns, id)
if err != nil {
return err
}
return archiveAudit(ctx, tx, c.ns, id, id, "archive:scheduler", "retention.job.created", "archive_job", "周期归档到期", nil, j)
})
if err != nil {
return err
}
}
return nil
}
// ClaimArchive 领取一批工作;五分钟租约超时后重领,以随机代次隔离迟到确认。
func (s *RetentionPolicyStore) ClaimArchive(ctx context.Context, storage core.ID) (job *retention.ManagedJob, err error) {
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
job = nil
j, err := scanArchiveJob(tx.QueryRowContext(ctx, `SELECT `+archiveJobColumns+` FROM orbit_archive_jobs WHERE storage_id=? AND (state='PENDING' OR (state='RUNNING' AND lease_until<=UTC_TIMESTAMP(6))) ORDER BY updated_at,id LIMIT 1 FOR UPDATE SKIP LOCKED`, storage))
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return err
}
lease, err := core.NewID()
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `UPDATE orbit_archive_jobs SET state='RUNNING',revision=revision+1,attempts=attempts+1,lease_id=?,lease_until=TIMESTAMPADD(SECOND,300,UTC_TIMESTAMP(6)),updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, lease, j.NamespaceID, j.ID)
if err != nil {
return err
}
claimed, err := readArchiveJob(ctx, tx, j.NamespaceID, j.ID)
if err != nil {
return err
}
job = &claimed
return nil
})
return
}
// FinishArchive 仅确认当前未过期领取;失败不释放计划占位,避免不断生成重复失败作业。
func (s *RetentionPolicyStore) FinishArchive(ctx context.Context, claim retention.ManagedJob, status retention.JobStatus, code string) error {
switch code {
case "", "FILE_CHECK_FAILED", "STORAGE_LIMIT", "STORAGE_BUSY", "POLICY_CHANGED", "PERMISSION_DENIED", "EXPORT_FAILED", "INTERRUPTED":
default:
return fault.Invalid("invalid archive result code")
}
return WithTx(ctx, s.db, func(tx *sql.Tx) error {
p, err := readArchivePlan(ctx, tx, claim.NamespaceID, claim.Kind)
if err != nil {
return err
}
j, err := readArchiveJob(ctx, tx, claim.NamespaceID, claim.ID)
if err != nil {
return err
}
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
if p.ActiveJobID == nil || *p.ActiveJobID != j.ID || j.State != "RUNNING" || j.LeaseID != claim.LeaseID || j.Revision != claim.Revision || j.LeaseUntil == nil || !j.LeaseUntil.After(now) {
return fault.Conflict
}
next := "PENDING"
if code != "" {
next = "FAILED"
} else if status.Completed {
next = "SUCCEEDED"
}
if code == "" && (status.JobID != j.ID || status.Batches < j.Batches || status.Roots < j.Roots || status.Bytes < j.Bytes || len(status.LastSHA256) != 64) {
return fault.Conflict
}
// 校验失败的部分文件不能覆盖最后一次数据库确认的可信检查点。
if code != "" {
status = retention.JobStatus{JobID: j.ID, Batches: j.Batches, Roots: j.Roots, Bytes: j.Bytes, LastSHA256: j.LastSHA256}
}
_, err = tx.ExecContext(ctx, `UPDATE orbit_archive_jobs SET state=?,revision=revision+1,batches=?,roots=?,bytes=?,last_sha256=?,error_code=?,lease_id=NULL,lease_until=NULL,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, next, status.Batches, status.Roots, status.Bytes, status.LastSHA256, code, j.NamespaceID, j.ID)
if err != nil {
return err
}
if next == "SUCCEEDED" {
if _, err = tx.ExecContext(ctx, `UPDATE orbit_archive_plans SET active_job_id=NULL WHERE namespace_id=? AND kind=? AND active_job_id=?`, j.NamespaceID, j.Kind, j.ID); err != nil {
return err
}
}
if next == "PENDING" {
return nil
}
out, err := readArchiveJob(ctx, tx, j.NamespaceID, j.ID)
if err != nil {
return err
}
return archiveAudit(ctx, tx, j.NamespaceID, j.ID, j.LeaseID, "archive:"+string(j.ID), "retention.job."+strings.ToLower(next), "archive_job", "归档执行结果", j, out)
})
}

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")
}

View File

@@ -1,4 +1,4 @@
-- Orbit fresh-install baseline, generated from migration version 30.
-- Orbit fresh-install baseline, generated from migration version 31.
-- This file intentionally contains no database-level relation constraints.
CREATE TABLE `orbit_application_resources` (
`application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度',
@@ -878,3 +878,52 @@ CREATE TABLE orbit_retention_policies (
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='命名空间历史数据保留策略,只配置窗口和开关,不自动删除';
-- 周期计划、固定范围作业和领取租约;不使用数据库外键,不删除历史源记录。
CREATE TABLE orbit_archive_plans (
namespace_id BINARY(16) NOT NULL COMMENT '所属命名空间,由事务核验',
kind VARCHAR(16) NOT NULL COMMENT '固定历史类别,同一命名空间每类一个计划',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '配置版本,后台触发不修改此版本',
enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否触发新作业,不中断已有作业',
policy_revision BIGINT UNSIGNED NOT NULL COMMENT '管理员确认的保留策略版本',
storage_id BINARY(16) NOT NULL COMMENT '固定归档卷身份,接管节点必须挂载同一物理卷',
interval_seconds INT UNSIGNED NOT NULL COMMENT '周期秒数,3600至2592000,错过多次合并一次',
batch_size INT UNSIGNED NOT NULL COMMENT '每批根记录上限,1至100',
max_bytes BIGINT UNSIGNED NOT NULL COMMENT '新作业累计文件字节预算,1MiB至1GiB',
next_run_at DATETIME(6) NOT NULL COMMENT '下次触发UTC时间,首次启用立即到期',
active_job_id BINARY(16) NULL 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,kind),
KEY ix_archive_plan_due(storage_id,enabled,next_run_at),
CONSTRAINT ck_archive_plan_kind CHECK(kind IN ('tasks','workflows','events','audit','deliveries','inbox','idempotency')),
CONSTRAINT ck_archive_plan_config CHECK(revision>0 AND policy_revision>0 AND interval_seconds BETWEEN 3600 AND 2592000 AND batch_size BETWEEN 1 AND 100 AND max_bytes BETWEEN 1048576 AND 1073741824)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='按命名空间和类别配置的周期归档计划,无外键';
CREATE TABLE orbit_archive_jobs (
namespace_id BINARY(16) NOT NULL COMMENT '所属命名空间,由事务核验',
id BINARY(16) NOT NULL COMMENT '作业及目录及文件清单的固定标识',
kind VARCHAR(16) NOT NULL COMMENT '创建时固定的历史类别',
plan_revision BIGINT UNSIGNED NOT NULL COMMENT '创建时固定的计划版本',
policy_revision BIGINT UNSIGNED NOT NULL COMMENT '创建时固定的保留策略版本',
storage_id BINARY(16) NOT NULL COMMENT '固定归档卷身份,不允许跨独立磁盘接管',
batch_size INT UNSIGNED NOT NULL COMMENT '创建时固定的单批根记录上限',
max_bytes BIGINT UNSIGNED NOT NULL COMMENT '累计文件字节预算,人工重试仅可提高',
state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING待领取RUNNING执行中SUCCEEDED完成FAILED待处理ABANDONED已关闭',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态并发版本,领取确认和人工操作递增',
attempts INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计领取次数,包含分批继续和崩溃接管',
batches INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已验证并入库的连续文件批次数',
roots BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认根记录总数',
bytes BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认清单及批次总字节数',
last_sha256 CHAR(64) NOT NULL DEFAULT '' COMMENT '已确认文件链尾摘要,恢复必须验证此前缀',
error_code VARCHAR(32) NOT NULL DEFAULT '' COMMENT '固定失败分类,不记录原始异常或业务内容',
lease_id BINARY(16) NULL COMMENT '领取随机代次,非执行中为空',
lease_until DATETIME(6) NULL COMMENT 'UTC租约截止,过期可接管,非执行中为空',
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,id),
KEY ix_archive_job_claim(storage_id,state,lease_until,updated_at),
KEY ix_archive_job_list(namespace_id,created_at,id),
CONSTRAINT ck_archive_job_state CHECK(state IN ('PENDING','RUNNING','SUCCEEDED','FAILED','ABANDONED')),
CONSTRAINT ck_archive_job_config CHECK(revision>0 AND plan_revision>0 AND policy_revision>0 AND batch_size BETWEEN 1 AND 100 AND max_bytes BETWEEN 1048576 AND 1073741824),
CONSTRAINT ck_archive_job_lease CHECK((state='RUNNING' AND lease_id IS NOT NULL AND lease_until IS NOT NULL) OR (state<>'RUNNING' AND lease_id IS NULL AND lease_until IS NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='持久归档作业及租约,只读源数据,无外键';

View File

@@ -26,7 +26,7 @@ var migrationFiles embed.FS
//go:embed baseline.sql
var freshInstallBaseline string
const freshInstallBaselineVersion uint64 = 30
const freshInstallBaselineVersion uint64 = 31
type Migration struct {
Version uint64

View File

@@ -0,0 +1,48 @@
-- 周期计划、固定范围作业和领取租约;不使用数据库外键,不删除历史源记录。
CREATE TABLE orbit_archive_plans (
namespace_id BINARY(16) NOT NULL COMMENT '所属命名空间,由事务核验',
kind VARCHAR(16) NOT NULL COMMENT '固定历史类别,同一命名空间每类一个计划',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '配置版本,后台触发不修改此版本',
enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否触发新作业,不中断已有作业',
policy_revision BIGINT UNSIGNED NOT NULL COMMENT '管理员确认的保留策略版本',
storage_id BINARY(16) NOT NULL COMMENT '固定归档卷身份,接管节点必须挂载同一物理卷',
interval_seconds INT UNSIGNED NOT NULL COMMENT '周期秒数,3600至2592000,错过多次合并一次',
batch_size INT UNSIGNED NOT NULL COMMENT '每批根记录上限,1至100',
max_bytes BIGINT UNSIGNED NOT NULL COMMENT '新作业累计文件字节预算,1MiB至1GiB',
next_run_at DATETIME(6) NOT NULL COMMENT '下次触发UTC时间,首次启用立即到期',
active_job_id BINARY(16) NULL 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,kind),
KEY ix_archive_plan_due(storage_id,enabled,next_run_at),
CONSTRAINT ck_archive_plan_kind CHECK(kind IN ('tasks','workflows','events','audit','deliveries','inbox','idempotency')),
CONSTRAINT ck_archive_plan_config CHECK(revision>0 AND policy_revision>0 AND interval_seconds BETWEEN 3600 AND 2592000 AND batch_size BETWEEN 1 AND 100 AND max_bytes BETWEEN 1048576 AND 1073741824)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='按命名空间和类别配置的周期归档计划,无外键';
CREATE TABLE orbit_archive_jobs (
namespace_id BINARY(16) NOT NULL COMMENT '所属命名空间,由事务核验',
id BINARY(16) NOT NULL COMMENT '作业及目录及文件清单的固定标识',
kind VARCHAR(16) NOT NULL COMMENT '创建时固定的历史类别',
plan_revision BIGINT UNSIGNED NOT NULL COMMENT '创建时固定的计划版本',
policy_revision BIGINT UNSIGNED NOT NULL COMMENT '创建时固定的保留策略版本',
storage_id BINARY(16) NOT NULL COMMENT '固定归档卷身份,不允许跨独立磁盘接管',
batch_size INT UNSIGNED NOT NULL COMMENT '创建时固定的单批根记录上限',
max_bytes BIGINT UNSIGNED NOT NULL COMMENT '累计文件字节预算,人工重试仅可提高',
state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING待领取RUNNING执行中SUCCEEDED完成FAILED待处理ABANDONED已关闭',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态并发版本,领取确认和人工操作递增',
attempts INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计领取次数,包含分批继续和崩溃接管',
batches INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已验证并入库的连续文件批次数',
roots BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认根记录总数',
bytes BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认清单及批次总字节数',
last_sha256 CHAR(64) NOT NULL DEFAULT '' COMMENT '已确认文件链尾摘要,恢复必须验证此前缀',
error_code VARCHAR(32) NOT NULL DEFAULT '' COMMENT '固定失败分类,不记录原始异常或业务内容',
lease_id BINARY(16) NULL COMMENT '领取随机代次,非执行中为空',
lease_until DATETIME(6) NULL COMMENT 'UTC租约截止,过期可接管,非执行中为空',
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,id),
KEY ix_archive_job_claim(storage_id,state,lease_until,updated_at),
KEY ix_archive_job_list(namespace_id,created_at,id),
CONSTRAINT ck_archive_job_state CHECK(state IN ('PENDING','RUNNING','SUCCEEDED','FAILED','ABANDONED')),
CONSTRAINT ck_archive_job_config CHECK(revision>0 AND plan_revision>0 AND policy_revision>0 AND batch_size BETWEEN 1 AND 100 AND max_bytes BETWEEN 1048576 AND 1073741824),
CONSTRAINT ck_archive_job_lease CHECK((state='RUNNING' AND lease_id IS NOT NULL AND lease_until IS NOT NULL) OR (state<>'RUNNING' AND lease_id IS NULL AND lease_until IS NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='持久归档作业及租约,只读源数据,无外键';

View File

@@ -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','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`)
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','orbit_archive_jobs','orbit_archive_plans','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)
}