Files
yatm/internal/executor/restore/init.go
T
Samuel CuiandClaude Opus 5.5 47b525b747 Write, read and publish encrypted tar Media
Compose tar FORMAT/APPEND, Restore, Scan and Verify through complete Media sessions and the configured encrypt adapter. Publish only complete members at normal or capacity/EW completion, maintain position maps, apply Logical or Auto read order, record passive read learning observations in each Job and refit learned read parameters after drive release. LTFS keeps its adapters and gains the shared read order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:01:44 +08:00

220 lines
7.5 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"
"google.golang.org/protobuf/proto"
"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
}
var metadata *entity.StorageMetadata
if position.StorageMetadata != nil {
metadata = proto.Clone(position.StorageMetadata).(*entity.StorageMetadata)
}
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...),
StorageMetadata: metadata,
MediaIdentity: media.Identity, MediaProfile: proto.Clone(media.Profile).(*entity.MediaProfile),
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
}