Files
yatm/internal/executor/restore/init.go
T
Samuel Cui 4fef653b71 Prepare YATM v1.0.0-alpha.2
Unify file queries and Scan execution, retain indexed Identical results,
improve Job and Preview workflows, and verify release packaging.
2026-10-05 19:08:49 +08:00

214 lines
7.2 KiB
Go

package restore
import (
"bytes"
"context"
"errors"
"fmt"
"math"
"github.com/samuelncui/yatm/entity"
"github.com/samuelncui/yatm/internal/executor"
"github.com/samuelncui/yatm/internal/library"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// applySpec freezes explicit archived versions; current originals never choose restore content.
func (a *jobRestoreRunner) applySpec(ctx context.Context, spec *entity.RestoreJobSpec) (returnErr error) {
// Exclude Library replacement for the complete manifest-building attempt.
if spec == nil || (len(spec.FileVersionIds) == 0 && len(spec.Selections) == 0) {
return fmt.Errorf("Restore requires FileVersions or file selections")
}
if _, err := a.exe.RestoreOutputPath(ctx, spec.Destination, ""); err != nil {
return err
}
// Select all versions before deciding which one may reconnect the source File.
names, err := newOutputNames(ctx, a)
if err != nil {
return err
}
defer func() { returnErr = errors.Join(returnErr, names.close()) }()
if err := names.rebuild(ctx); err != nil {
return err
}
var config Config
if err := a.db.WithContext(ctx).First(&config, 1).Error; err != nil {
return err
}
if !config.ManifestFrozen {
a.dropProgress()
progress := a.getProgress()
if err := a.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return a.buildManifest(ctx, spec, tx, progress) }); err != nil {
return err
}
}
if err := a.prepareOutputs(ctx, names); err != nil {
return err
}
a.dropProgress()
return nil
}
func (a *jobRestoreRunner) buildManifest(ctx context.Context, spec *entity.RestoreJobSpec, db *gorm.DB, progress *executor.Progress) error {
// Rebuild only incomplete manifests; policy mismatches never freeze a partial executable selection.
if err := library.ValidateRestoreVersionSelection(spec.VersionPolicy, spec.SkipUnmatchedVersions); err != nil {
return err
}
if err := a.resetManifest(ctx, db); err != nil {
return err
}
var totalFiles, totalBytes int64
resources := make([]executor.JobProperty, 0, batchSize)
seenMedia := make(map[int64]bool, batchSize)
flushResources := func() error {
if err := a.exe.AddJobProperties(ctx, a.job.ID, resources...); err != nil {
return err
}
resources = resources[:0]
seenMedia = make(map[int64]bool, batchSize)
return nil
}
appendVersion := func(item *library.RestoreSelectionItem) error {
// Missing policy matches require explicit omission; unavailable physical copies never do.
version := item.Version
if version == nil {
if spec.SkipUnmatchedVersions {
a.logger.Infof("Skipping File %d: no version matches the restore cutoff (%s)", item.File.ID, item.Resolution.Match)
return nil
}
return fmt.Errorf("File %d has no matching restore version (%s)", item.File.ID, item.Resolution.Match)
}
id := version.ID
if !version.HasRestoreIntegrity() {
return fmt.Errorf("FileVersion %d has no supported restore integrity facts", id)
}
// Freeze logical output names and reserve a collision-safe destination for this version.
target := item.Path
if err := entity.ValidateRelativePath(target); err != nil {
return err
}
file := item.File
if err := db.Create(&File{ItemID: id, FileID: version.FileID, FileVersionID: id, ParentID: file.ParentID, Name: file.Name, LastArchivedAtNS: version.LastArchivedAtNS, DesiredPath: target, Signature: version.Signature, Hash: version.Hash, Size: version.Size, Mode: version.Mode, MtimeNS: version.MtimeNS}).Error; err != nil {
return err
}
// Page independent physical candidates without changing version ownership.
var found bool
passes := 1
if spec.AllowDamagedCopies {
passes = 2
}
for pass := 0; pass < passes; pass++ {
var after int64
for {
positions, more, err := a.exe.Lib().ListContentCopies(ctx, version.Signature, after, batchSize)
if err != nil {
return err
}
copies := make([]*Copy, 0, len(positions))
for _, position := range positions {
eligible := library.PositionRestoreEligible(position.Health)
if (pass == 0) != eligible {
continue
}
if !eligible && position.Health != entity.PositionHealth_POSITION_HEALTH_DAMAGED && position.Health != entity.PositionHealth_POSITION_HEALTH_UNREADABLE {
continue
}
if position.Size != version.Size || !bytes.Equal(position.Hash, version.Hash) {
return fmt.Errorf("Position %d disagrees with FileVersion %d integrity facts", position.ID, id)
}
if err := entity.ValidateRelativePath(position.Path); err != nil {
return err
}
media, err := a.exe.Lib().GetMedia(ctx, position.MediaID)
if err != nil {
return err
}
copies = append(copies, &Copy{
ItemID: id, Status: entity.CopyStatus_COPY_STATUS_PENDING, MediaID: position.MediaID,
Health: entity.PositionHealth_POSITION_HEALTH_UNKNOWN,
MediaPath: position.Path, StorageOrder: append([]byte{}, position.StorageOrder...),
MediaIdentity: media.Identity, MediaProfile: media.Profile,
PositionID: position.ID})
if !seenMedia[position.MediaID] {
seenMedia[position.MediaID] = true
resources = append(resources, executor.JobProperty{
Key: executor.JobPropertyMedia, Value: position.MediaID})
if len(resources) == batchSize {
if err := flushResources(); err != nil {
return err
}
}
}
}
if len(copies) > 0 {
found = true
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "item_id"}, {Name: "media_id"}}, DoNothing: true,
}).Create(&copies).Error; err != nil {
return err
}
}
if !more {
break
}
after = positions[len(positions)-1].ID
}
}
// A version without any known copy cannot become an executable restore item.
if !found {
return fmt.Errorf("FileVersion %d has no archived copy", id)
}
if totalFiles == math.MaxInt64 || version.Size > math.MaxInt64-totalBytes {
return fmt.Errorf("Restore totals exceed the supported range")
}
totalFiles++
totalBytes += version.Size
progress.SetGlobalTotal(totalBytes, totalFiles)
return nil
}
// Resolve choices through the same paged policy walker used by the waitlist estimate.
if err := a.exe.Lib().WalkRestoreSelections(ctx, spec.Selections, spec.FileVersionIds, spec.VersionPolicy, appendVersion); err != nil {
return err
}
if totalFiles == 0 {
return fmt.Errorf("Restore selection contains no matching saved versions")
}
if err := flushResources(); err != nil {
return err
}
if err := freezeFileSelections(db); err != nil {
return err
}
return db.Model(&Config{}).Where("id = ?", 1).Update("manifest_frozen", true).Error
}
func (a *jobRestoreRunner) resetManifest(ctx context.Context, db *gorm.DB) error {
// Build the initial manifest from the complete creation specification.
if err := db.WithContext(ctx).Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&Copy{}).Error; err != nil {
return err
}
return db.WithContext(ctx).Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&File{}).Error
}
func uniquePositiveIDs(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
result := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, found := seen[id]; found {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}