feat: schedule persistent archive jobs with console management
This commit is contained in:
@@ -16,6 +16,12 @@ import (
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
)
|
||||
|
||||
// ErrArchiveLimit 表示达到显式容量边界,源数据与已发布文件均保留。
|
||||
var ErrArchiveLimit = errors.New("archive byte limit reached; source and saved progress retained")
|
||||
|
||||
// ErrArchiveBusy 表示原作业被另一个进程持锁,不允许并发写入。
|
||||
var ErrArchiveBusy = errors.New("archive job is locked")
|
||||
|
||||
const maxJobBatches = 10000
|
||||
const maxBatchFileBytes = 64 << 20
|
||||
|
||||
@@ -47,8 +53,11 @@ type JobBatch struct {
|
||||
|
||||
// JobOptions 控制本次进程的工作量,重试时可显式提高上限,不改变作业身份。
|
||||
type JobOptions struct {
|
||||
MaxBatches int // MaxBatches 本次最多新增批次数,1–1000。
|
||||
MaxBytes int64 // MaxBytes 作业清单和全部批次文件总字节上限,1 MiB–100 GiB;不自动删除旧文件腾空间。
|
||||
JobID core.ID // JobID 周期作业预分配身份;空值保持CLI自动生成行为。
|
||||
RequireExisting bool // RequireExisting 接管时必须存在原清单,禁止卷丢失后重建。
|
||||
Checkpoint *JobStatus // Checkpoint 数据库已确认的链前缀,校验后才允许继续导出。
|
||||
MaxBatches int // MaxBatches 本次最多新增批次数,1–1000。
|
||||
MaxBytes int64 // MaxBytes 作业清单和全部批次文件总字节上限,1 MiB–100 GiB;不自动删除旧文件腾空间。
|
||||
}
|
||||
|
||||
// JobStatus 是可输出到日志的摘要,不包含业务正文或游标。
|
||||
@@ -84,7 +93,10 @@ func RunArchive(ctx context.Context, directory string, spec JobSpec, options Job
|
||||
if options.MaxBatches < 1 || options.MaxBatches > 1000 || options.MaxBytes < 1<<20 || options.MaxBytes > 100<<30 || export == nil {
|
||||
return JobStatus{}, errors.New("invalid archive execution limits")
|
||||
}
|
||||
root, lock, err := openJob(directory, true)
|
||||
if options.JobID != "" && options.JobID.Validate() != nil {
|
||||
return JobStatus{}, errors.New("invalid archive job id")
|
||||
}
|
||||
root, lock, err := openJob(directory, !options.RequireExisting)
|
||||
if err != nil {
|
||||
return JobStatus{}, err
|
||||
}
|
||||
@@ -94,14 +106,20 @@ func RunArchive(ctx context.Context, directory string, spec JobSpec, options Job
|
||||
return JobStatus{}, err
|
||||
}
|
||||
if _, err := root.Lstat("manifest.json"); errors.Is(err, os.ErrNotExist) {
|
||||
if options.RequireExisting {
|
||||
return JobStatus{}, errors.New("saved archive manifest is missing")
|
||||
}
|
||||
// 不在未知目录或缺少清单的残留批次上悄悄创建新身份。
|
||||
entries, err := jobEntries(root)
|
||||
if err != nil || len(entries) != 1 || entries[0].Name() != ".lock" {
|
||||
return JobStatus{}, errors.New("archive directory is not empty or manifest is missing")
|
||||
}
|
||||
id, err := core.NewID()
|
||||
if err != nil {
|
||||
return JobStatus{}, err
|
||||
id := options.JobID
|
||||
if id == "" {
|
||||
id, err = core.NewID()
|
||||
if err != nil {
|
||||
return JobStatus{}, err
|
||||
}
|
||||
}
|
||||
manifest := JobManifest{1, id, time.Now().UTC(), spec}
|
||||
raw, err := json.Marshal(manifest)
|
||||
@@ -118,9 +136,23 @@ func RunArchive(ctx context.Context, directory string, spec JobSpec, options Job
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
if manifest.Spec != spec {
|
||||
if manifest.Spec != spec || (options.JobID != "" && manifest.JobID != options.JobID) {
|
||||
return status, errors.New("archive scope differs from saved manifest; resume with original arguments")
|
||||
}
|
||||
|
||||
if saved := options.Checkpoint; saved != nil && saved.LastSHA256 != "" {
|
||||
if saved.JobID != status.JobID || saved.Batches > status.Batches || saved.Roots > status.Roots || saved.Bytes > status.Bytes {
|
||||
return status, errors.New("archive progress is behind database checkpoint")
|
||||
}
|
||||
name := "manifest.json"
|
||||
if saved.Batches > 0 {
|
||||
name = batchName(saved.Batches)
|
||||
}
|
||||
raw, err := readJobFile(root, name, maxBatchFileBytes)
|
||||
if err != nil || digest(raw) != saved.LastSHA256 {
|
||||
return status, errors.New("archive prefix differs from database checkpoint")
|
||||
}
|
||||
}
|
||||
if err := syncJobDirectory(root); err != nil {
|
||||
return status, err
|
||||
}
|
||||
@@ -145,7 +177,7 @@ func RunArchive(ctx context.Context, directory string, spec JobSpec, options Job
|
||||
return status, err
|
||||
}
|
||||
if len(raw) > maxBatchFileBytes || int64(len(raw)) > options.MaxBytes-status.Bytes {
|
||||
return status, errors.New("archive byte limit reached; source and saved progress retained")
|
||||
return status, ErrArchiveLimit
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return status, err
|
||||
@@ -273,7 +305,7 @@ func readJob(ctx context.Context, root *os.Root, maxBytes int64) (JobManifest, J
|
||||
return fail(err)
|
||||
}
|
||||
if int64(len(raw)) > maxBytes-status.Bytes {
|
||||
return fail(errors.New("archive byte limit reached"))
|
||||
return fail(ErrArchiveLimit)
|
||||
}
|
||||
var packet JobBatch
|
||||
if decodeJobJSON(raw, &packet) != nil {
|
||||
|
||||
@@ -21,7 +21,10 @@ func lockJob(root *os.Root) (*os.File, error) {
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
f.Close()
|
||||
return nil, errors.New("archive job is locked or filesystem does not support locking")
|
||||
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
|
||||
return nil, ErrArchiveBusy
|
||||
}
|
||||
return nil, errors.New("archive filesystem does not support locking")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
@@ -259,3 +259,33 @@ func TestArchiveJobWriteFailureDoesNotAdvance(t *testing.T) {
|
||||
t.Fatalf("write recovery=%+v err=%v", final, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedArchiveChecksDatabasePrefixAndMissingVolume(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := filepath.Join(t.TempDir(), "job")
|
||||
id, _ := core.NewID()
|
||||
calls := 0
|
||||
options := testJobOptions()
|
||||
options.JobID = id
|
||||
options.MaxBatches = 1
|
||||
first, err := RunArchive(ctx, dir, testJobSpec(), options, testJobExporter(t, &calls))
|
||||
if err != nil || first.JobID != id {
|
||||
t.Fatal(first, err)
|
||||
}
|
||||
options.Checkpoint = &first
|
||||
options.RequireExisting = true
|
||||
forged := first
|
||||
forged.LastSHA256 = strings.Repeat("0", 64)
|
||||
options.Checkpoint = &forged
|
||||
if _, err = RunArchive(ctx, dir, testJobSpec(), options, testJobExporter(t, &calls)); err == nil || calls != 1 {
|
||||
t.Fatal("untrusted prefix accepted")
|
||||
}
|
||||
options.Checkpoint = &first
|
||||
if _, err = RunArchive(ctx, filepath.Join(t.TempDir(), "missing"), testJobSpec(), options, testJobExporter(t, &calls)); err == nil || calls != 1 {
|
||||
t.Fatal("missing volume silently recreated")
|
||||
}
|
||||
second, err := RunArchive(ctx, dir, testJobSpec(), options, testJobExporter(t, &calls))
|
||||
if err != nil || !second.Completed || second.JobID != id {
|
||||
t.Fatal(second, err)
|
||||
}
|
||||
}
|
||||
|
||||
209
internal/retention/runtime.go
Normal file
209
internal/retention/runtime.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
)
|
||||
|
||||
// RuntimeStore 只供受信任的归档执行器使用,控制台不能直接领取或确认租约。
|
||||
type RuntimeStore interface {
|
||||
ScheduleArchive(context.Context, core.ID) error
|
||||
ClaimArchive(context.Context, core.ID) (*ManagedJob, error)
|
||||
FinishArchive(context.Context, ManagedJob, JobStatus, string) error
|
||||
ExportRetentionForJob(context.Context, core.ID, core.ID, ArchiveInput) (ArchiveSnapshot, error)
|
||||
}
|
||||
|
||||
// ArchiveRuntime 固定一个部署卷,每次最多运行一批;所有共享该卷的节点串行占用文件锁。
|
||||
type ArchiveRuntime struct {
|
||||
Directory string // Directory 管理员配置的绝对路径,0700权限,不可使用容器临时层。
|
||||
StorageID core.ID // StorageID 卷内标记必须相同;不是共享存储的替代品。
|
||||
MaxBytes int64 // MaxBytes 整个卷内归档文件总字节上限,包含未完成作业。
|
||||
Store RuntimeStore // Store 持久化计划、领取和检查点。
|
||||
}
|
||||
|
||||
// Init 仅在空私有目录建立卷标记,已有文件缺少标记时拒绝猜测身份。
|
||||
func (r *ArchiveRuntime) Init() error {
|
||||
if r.StorageID.Validate() != nil || !filepath.IsAbs(r.Directory) || r.MaxBytes < 1<<20 || r.Store == nil {
|
||||
return errors.New("invalid archive runtime configuration")
|
||||
}
|
||||
|
||||
root, lock, err := openJob(r.Directory, true)
|
||||
for attempt := 0; errors.Is(err, ErrArchiveBusy) && attempt < 20; attempt++ {
|
||||
// 已有卷身份文件不可变;其他节点正在执行时,启动节点可以只读核对,
|
||||
// 不必等待整个文件批次释放卷锁。首次发布尚未结束时短暂等待。
|
||||
reader, readErr := os.OpenRoot(r.Directory)
|
||||
if readErr == nil {
|
||||
marker, e := readJobFile(reader, ".storage-id", 64)
|
||||
reader.Close()
|
||||
if e == nil {
|
||||
if string(marker) != string(r.StorageID) {
|
||||
return errors.New("archive volume identity mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
root, lock, err = openJob(r.Directory, true)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
defer lock.Close()
|
||||
if err := discardStaging(root); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := readJobFile(root, ".storage-id", 64)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
entries, e := jobEntries(root)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != ".lock" {
|
||||
return errors.New("archive volume identity is missing")
|
||||
}
|
||||
return publishFile(root, ".storage-id", []byte(r.StorageID))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if string(data) != string(r.StorageID) {
|
||||
return errors.New("archive volume identity mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tick 不在数据库事务中进行文件I/O;发布后确认失败时,由同一作业验证文件再收敛。
|
||||
func (r *ArchiveRuntime) Tick(ctx context.Context) error {
|
||||
root, lock, err := openJob(r.Directory, false)
|
||||
if errors.Is(err, ErrArchiveBusy) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
defer lock.Close()
|
||||
marker, err := readJobFile(root, ".storage-id", 64)
|
||||
if err != nil || string(marker) != string(r.StorageID) {
|
||||
return errors.New("archive volume identity mismatch")
|
||||
}
|
||||
if err = r.Store.ScheduleArchive(ctx, r.StorageID); err != nil {
|
||||
return err
|
||||
}
|
||||
job, err := r.Store.ClaimArchive(ctx, r.StorageID)
|
||||
if err != nil || job == nil {
|
||||
return err
|
||||
}
|
||||
name := string(job.NamespaceID) + "-" + string(job.ID)
|
||||
files := 0
|
||||
total, current, err := archiveVolumeUsage(ctx, root, ".", name, &files)
|
||||
|
||||
code := ""
|
||||
var status JobStatus
|
||||
if err != nil {
|
||||
code = "FILE_CHECK_FAILED"
|
||||
if errors.Is(err, ErrArchiveLimit) {
|
||||
code = "STORAGE_LIMIT"
|
||||
}
|
||||
} else {
|
||||
budget := min(job.MaxBytes, r.MaxBytes-total+current)
|
||||
if budget < 1<<20 {
|
||||
code = "STORAGE_LIMIT"
|
||||
} else {
|
||||
checkpoint := JobStatus{JobID: job.ID, Batches: job.Batches, Roots: job.Roots, Bytes: job.Bytes, LastSHA256: job.LastSHA256}
|
||||
options := JobOptions{JobID: job.ID, RequireExisting: job.Attempts > 1, Checkpoint: &checkpoint, MaxBatches: 1, MaxBytes: budget}
|
||||
status, err = RunArchive(ctx, filepath.Join(r.Directory, name), JobSpec{job.NamespaceID, job.Kind, job.PolicyRevision, job.BatchSize}, options, func(ctx context.Context, id core.ID, in ArchiveInput) (ArchiveSnapshot, error) {
|
||||
out, err := r.Store.ExportRetentionForJob(ctx, job.NamespaceID, id, in)
|
||||
if err != nil {
|
||||
code = "EXPORT_FAILED"
|
||||
switch fault.Code(err) {
|
||||
case "STATE_CONFLICT":
|
||||
code = "POLICY_CHANGED"
|
||||
case "PERMISSION_DENIED":
|
||||
code = "PERMISSION_DENIED"
|
||||
}
|
||||
}
|
||||
return out, err
|
||||
})
|
||||
if err != nil && code == "" {
|
||||
code = "FILE_CHECK_FAILED"
|
||||
if errors.Is(err, ErrArchiveLimit) {
|
||||
code = "STORAGE_LIMIT"
|
||||
}
|
||||
if errors.Is(err, ErrArchiveBusy) {
|
||||
code = "STORAGE_BUSY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 关闭/超时不写成文件损坏;保留租约,下一节点在到期后从同一文件链接管。
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
finish, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
return r.Store.FinishArchive(finish, *job, status, code)
|
||||
}
|
||||
|
||||
// archiveVolumeUsage 分块读取目录项,避免 WalkDir 在大目录中先一次载入全部文件名。
|
||||
// 平台卷只有根目录和单层作业目录,意外嵌套或链接一律拒绝。
|
||||
func archiveVolumeUsage(ctx context.Context, root *os.Root, directory, currentJob string, count *int) (total, current int64, err error) {
|
||||
dir, err := root.Open(directory)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer dir.Close()
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
entries, readErr := dir.ReadDir(256)
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
return 0, 0, readErr
|
||||
}
|
||||
for _, entry := range entries {
|
||||
*count++
|
||||
if *count > 100000 {
|
||||
return 0, 0, ErrArchiveLimit
|
||||
}
|
||||
if entry.Type()&fs.ModeSymlink != 0 {
|
||||
return 0, 0, errors.New("archive volume contains a symlink")
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if directory != "." {
|
||||
return 0, 0, errors.New("archive volume contains nested directories")
|
||||
}
|
||||
n, j, err := archiveVolumeUsage(ctx, root, entry.Name(), currentJob, count)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
total += n
|
||||
current += j
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return 0, 0, errors.New("archive volume contains a special file")
|
||||
}
|
||||
total += info.Size()
|
||||
if directory == currentJob {
|
||||
current += info.Size()
|
||||
}
|
||||
}
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
return total, current, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
96
internal/retention/runtime_test.go
Normal file
96
internal/retention/runtime_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
)
|
||||
|
||||
type idleArchiveStore struct {
|
||||
RuntimeStore
|
||||
scheduled int
|
||||
}
|
||||
|
||||
func (s *idleArchiveStore) ScheduleArchive(context.Context, core.ID) error { s.scheduled++; return nil }
|
||||
func (s *idleArchiveStore) ClaimArchive(context.Context, core.ID) (*ManagedJob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestArchiveVolumeIdentityLockAndInitializationRecovery(t *testing.T) {
|
||||
store := &idleArchiveStore{}
|
||||
id, _ := core.NewID()
|
||||
runtime := &ArchiveRuntime{Directory: filepath.Join(t.TempDir(), "volume"), StorageID: id, MaxBytes: 8 << 20, Store: store}
|
||||
if err := runtime.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, lock, err := openJob(runtime.Directory, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 其他节点正在执行时,新节点仍可读取不可变卷身份并正常启动。
|
||||
if err = runtime.Init(); err != nil {
|
||||
t.Fatal("another node could not initialize", err)
|
||||
}
|
||||
other := *runtime
|
||||
other.StorageID, _ = core.NewID()
|
||||
if err = other.Init(); err == nil {
|
||||
t.Fatal("wrong volume accepted while busy")
|
||||
}
|
||||
if err = runtime.Tick(context.Background()); err != nil || store.scheduled != 0 {
|
||||
t.Fatal("busy volume scheduled work", err)
|
||||
}
|
||||
lock.Close()
|
||||
root.Close()
|
||||
if err = runtime.Tick(context.Background()); err != nil || store.scheduled != 1 {
|
||||
t.Fatal("unlocked volume did not schedule", err)
|
||||
}
|
||||
if err = other.Init(); err == nil {
|
||||
t.Fatal("wrong volume accepted")
|
||||
}
|
||||
// 首次身份文件原子发布前崩溃,只允许丢弃本工具的安全临时文件。
|
||||
fresh := *runtime
|
||||
fresh.Directory = filepath.Join(t.TempDir(), "interrupted")
|
||||
if err = os.Mkdir(fresh.Directory, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = os.WriteFile(filepath.Join(fresh.Directory, ".write.tmp"), []byte(id), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = fresh.Init(); err != nil {
|
||||
t.Fatal("volume init did not recover", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveVolumeScanCountsAllJobsAndRejectsLinks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
job := "job"
|
||||
if err := os.Mkdir(filepath.Join(dir, job), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "other"), []byte("volume"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, job, "batch"), []byte("job"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
count := 0
|
||||
total, current, err := archiveVolumeUsage(context.Background(), root, ".", job, &count)
|
||||
if err != nil || total != 9 || current != 3 || count != 3 {
|
||||
t.Fatal(total, current, count, err)
|
||||
}
|
||||
if err = os.Symlink("other", filepath.Join(dir, "unexpected")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count = 0
|
||||
if _, _, err = archiveVolumeUsage(context.Background(), root, ".", job, &count); err == nil {
|
||||
t.Fatal("volume symlink accepted")
|
||||
}
|
||||
}
|
||||
101
internal/retention/scheduler.go
Normal file
101
internal/retention/scheduler.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
// ArchiveKinds 是周期归档支持的固定类别,不接受任意表名或 SQL。
|
||||
var ArchiveKinds = [...]string{"tasks", "workflows", "events", "audit", "deliveries", "inbox", "idempotency"}
|
||||
|
||||
// ArchivePlan 是命名空间内单一类别的周期计划;失败作业阻止本类别再生成新作业。
|
||||
type ArchivePlan struct {
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 由登录会话确定。
|
||||
Kind string `json:"kind"` // Kind 同一命名空间内唯一的归档类别。
|
||||
Revision uint64 `json:"revision,string"` // Revision 配置版本;尚未保存时为0,后台触发不改变配置版本。
|
||||
Enabled bool `json:"enabled"` // Enabled 仅控制新作业触发,关闭不会中断已有作业。
|
||||
PolicyRevision uint64 `json:"policy_revision,string"` // PolicyRevision 管理员确认的保留策略版本,变更策略后须重新保存计划。
|
||||
StorageID core.ID `json:"storage_id"` // StorageID 固定归档卷身份,由部署配置提供,不接受浏览器任意路径。
|
||||
IntervalSeconds uint32 `json:"interval_seconds"` // IntervalSeconds 周期秒数,3600至2592000;错过多次只合并触发一次。
|
||||
BatchSize int `json:"batch_size"` // BatchSize 单批根记录数,1至100。
|
||||
MaxBytes int64 `json:"max_bytes"` // MaxBytes 每个作业的文件字节上限,1 MiB至1 GiB。
|
||||
NextRunAt *time.Time `json:"next_run_at"` // NextRunAt 下次触发UTC时间,未保存时为空;首次启用立即到期。
|
||||
ActiveJobID *core.ID `json:"active_job_id"` // ActiveJobID 未完成或待处理失败作业;成功或人工关闭后清空。
|
||||
UpdatedAt *time.Time `json:"updated_at"` // UpdatedAt 最后配置修改UTC时间,未保存为空。
|
||||
}
|
||||
|
||||
// PlanInput 完整替换计划配置;保存与管理审计、幂等回执同事务。
|
||||
type PlanInput struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string" required:"true"` // ExpectedRevision 已读取的计划版本。
|
||||
PolicyRevision uint64 `json:"policy_revision,string" required:"true"` // PolicyRevision 已确认的保留策略版本。
|
||||
Enabled bool `json:"enabled" required:"true"` // Enabled 是否开始周期触发。
|
||||
IntervalSeconds uint32 `json:"interval_seconds" required:"true"` // IntervalSeconds 周期秒数。
|
||||
BatchSize int `json:"batch_size" required:"true"` // BatchSize 每批根记录数。
|
||||
MaxBytes int64 `json:"max_bytes" required:"true"` // MaxBytes 单作业总文件预算,字节。
|
||||
Reason string `json:"reason" required:"true"` // Reason 单行操作原因,最多1024个UTF-8字节。
|
||||
}
|
||||
|
||||
// ManagedJob 保存周期作业权威状态;文件内容和游标不通过管理列表返回。
|
||||
type ManagedJob struct {
|
||||
ID core.ID `json:"id"` // ID 与目录名及文件清单作业ID一致。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 所属命名空间。
|
||||
Kind string `json:"kind"` // Kind 固定归档类别。
|
||||
PlanRevision uint64 `json:"plan_revision,string"` // PlanRevision 创建时固定的计划配置版本。
|
||||
PolicyRevision uint64 `json:"policy_revision,string"` // PolicyRevision 作业期间不可替换的保留策略版本。
|
||||
StorageID core.ID `json:"storage_id"` // StorageID 固定归档卷身份,接管节点必须挂载同一物理卷。
|
||||
BatchSize int `json:"batch_size"` // BatchSize 创建时固定的单批根记录上限。
|
||||
MaxBytes int64 `json:"max_bytes"` // MaxBytes 累计字节预算,仅失败重试时允许提高。
|
||||
State string `json:"state"` // State 为PENDING/RUNNING/SUCCEEDED/FAILED/ABANDONED。
|
||||
Revision uint64 `json:"revision,string"` // Revision 每次领取、确认、人工操作递增,用于防止迟到覆盖。
|
||||
Attempts uint32 `json:"attempts"` // Attempts 累计批次领取次数,包含崩溃接管,不等于业务任务重试次数。
|
||||
Batches int `json:"batches"` // Batches 已验证并入库的连续批次数。
|
||||
Roots int64 `json:"roots"` // Roots 已验证根记录总数。
|
||||
Bytes int64 `json:"bytes"` // Bytes 已验证清单和批次总字节数。
|
||||
LastSHA256 string `json:"last_sha256"` // LastSHA256 数据库确认的文件链尾摘要;恢复必须验证此前缀。
|
||||
ErrorCode string `json:"error_code"` // ErrorCode 固定错误分类,不保存原始路径、业务正文或数据库错误。
|
||||
LeaseID core.ID `json:"-"` // LeaseID 当前领取随机代次,不向浏览器返回。
|
||||
LeaseUntil *time.Time `json:"lease_until"` // LeaseUntil UTC租约截止,非执行中为空;过期可接管。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 作业受理UTC时间。
|
||||
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最近状态变更UTC时间。
|
||||
}
|
||||
|
||||
// JobActionInput 是失败作业的人工恢复或关闭输入,必须留有原因。
|
||||
type JobActionInput struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string" required:"true"` // ExpectedRevision 已读取的作业版本。
|
||||
Action string `json:"action" required:"true"` // Action 仅retry或abandon;后者保留已有文件与审计。
|
||||
MaxBytes int64 `json:"max_bytes" required:"true"` // MaxBytes 重试可提高预算,关闭时保持原值。
|
||||
Reason string `json:"reason" required:"true"` // Reason 人工操作原因。
|
||||
}
|
||||
|
||||
// JobPage 采用已返回作业ID作为翻页位置;位置必须属于当前命名空间。
|
||||
type JobPage struct {
|
||||
Items []ManagedJob `json:"items"` // Items 本页最多20个作业。
|
||||
NextBefore string `json:"next_before"` // NextBefore 下一页位置;空表示当前已到末页。
|
||||
}
|
||||
|
||||
// SchedulerStore 将控制台管理与后台执行的持久事务统一到存储层。
|
||||
type SchedulerStore interface {
|
||||
ListArchivePlans(context.Context, auth.Token) ([]ArchivePlan, error)
|
||||
SaveArchivePlan(context.Context, auth.Token, string, string, core.ID, PlanInput) (ArchivePlan, error)
|
||||
ListArchiveJobs(context.Context, auth.Token, string) (JobPage, error)
|
||||
ActArchiveJob(context.Context, auth.Token, core.ID, string, JobActionInput) (ManagedJob, error)
|
||||
}
|
||||
|
||||
func ValidReason(s string) bool {
|
||||
return strings.TrimSpace(s) != "" && utf8.ValidString(s) && len(s) <= 1024 && !strings.ContainsAny(s, "\r\n")
|
||||
}
|
||||
func (in PlanInput) Validate(namespace core.ID, kind string) error {
|
||||
if err := (JobSpec{namespace, kind, in.PolicyRevision, in.BatchSize}).Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.IntervalSeconds < 3600 || in.IntervalSeconds > 2592000 || in.MaxBytes < 1<<20 || in.MaxBytes > 1<<30 || !ValidReason(in.Reason) {
|
||||
return errors.New("invalid archive interval, byte budget or reason")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user