mirror of
https://github.com/samuelncui/yatm.git
synced 2026-11-08 04:18:03 +00:00
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>
185 lines
7.8 KiB
Go
185 lines
7.8 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/samuelncui/acp"
|
|
"github.com/samuelncui/yatm/entity"
|
|
"github.com/samuelncui/yatm/internal/executor/observation"
|
|
"github.com/samuelncui/yatm/internal/library"
|
|
"github.com/samuelncui/yatm/internal/resource"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
func TestCachedObservedContentUsesObservedMetadata(t *testing.T) {
|
|
// An inaccessible cache supplies no admission evidence and never triggers a content read.
|
|
hash, signature, err := cachedObservedContent(filepath.Join(t.TempDir(), "missing"), 1, 1)
|
|
require.NoError(t, err)
|
|
require.Nil(t, hash)
|
|
require.Nil(t, signature)
|
|
|
|
// Prime a real disposable cache through ACP before testing already-observed metadata.
|
|
filename, observed := writeObservedFile(t, "cached", []byte("observed content"))
|
|
facts, err := HashObservedContent(context.Background(), filename, observed)
|
|
require.NoError(t, err)
|
|
_, valid, err := acp.ReadCachedSignature(filename)
|
|
require.NoError(t, err)
|
|
if !valid {
|
|
t.Skip("temporary filesystem does not support signature xattrs")
|
|
}
|
|
|
|
// A cache valid for the live path cannot replace a different existing observation.
|
|
for _, test := range []struct {
|
|
name string
|
|
size, mtimeNS int64
|
|
}{
|
|
{name: "size", size: observed.Size() + 1, mtimeNS: observed.ModTime().UnixNano()},
|
|
{name: "mtime", size: observed.Size(), mtimeNS: observed.ModTime().UnixNano() + 1},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
hash, signature, err := cachedObservedContent(filename, test.size, test.mtimeNS)
|
|
require.NoError(t, err)
|
|
require.Nil(t, hash)
|
|
require.Nil(t, signature)
|
|
})
|
|
}
|
|
|
|
// Matching observations own their hash and signature bytes independently of later callers.
|
|
hash, signature, err = cachedObservedContent(filename, observed.Size(), observed.ModTime().UnixNano())
|
|
require.NoError(t, err)
|
|
require.Equal(t, facts.Sha256, hash)
|
|
require.Equal(t, facts.Signature, signature)
|
|
hash[0] ^= 0xff
|
|
signature[0] ^= 0xff
|
|
nextHash, nextSignature, err := cachedObservedContent(filename, observed.Size(), observed.ModTime().UnixNano())
|
|
require.NoError(t, err)
|
|
require.Equal(t, facts.Sha256, nextHash)
|
|
require.Equal(t, facts.Signature, nextSignature)
|
|
}
|
|
|
|
func TestSelectionStageAndCaptureReuseBatchFacts(t *testing.T) {
|
|
// Seed known original/native facts so the measured path does not need content hashing.
|
|
ctx := context.Background()
|
|
exe := setupTestExecutor(t)
|
|
require.NoError(t, exe.lib.AutoMigrate())
|
|
root, err := filepath.EvalSymlinks(t.TempDir())
|
|
require.NoError(t, err)
|
|
exe = New(exe.db, exe.lib, nil, Paths{Access: []AccessRange{{Root: root}}}, Scripts{}, nil)
|
|
location := &library.Location{Name: "batch", RootPath: root, ExecutorID: "local"}
|
|
require.NoError(t, exe.lib.CreateLocation(ctx, location))
|
|
var ids []int64
|
|
var items []*observation.Item
|
|
hash := sha256.Sum256([]byte("content"))
|
|
for i := 0; i < 201; i++ {
|
|
file := &library.File{Name: fmt.Sprintf("file-%03d", i)}
|
|
require.NoError(t, exe.lib.SaveFile(ctx, file))
|
|
name := filepath.Join(root, file.Name)
|
|
require.NoError(t, os.WriteFile(name, []byte("content"), 0644))
|
|
info, err := os.Stat(name)
|
|
require.NoError(t, err)
|
|
original := &library.FileLocation{FileID: file.ID, LocationID: location.ID, Path: file.Name, Mode: uint32(info.Mode()), Size: info.Size(), MtimeNS: info.ModTime().UnixNano(), Hash: hash[:], Signature: []byte("opaque")}
|
|
require.NoError(t, exe.db.Create(original).Error)
|
|
keys := ObserveTracking(location, info)
|
|
for _, key := range keys {
|
|
key.FileID, key.LocationID = file.ID, location.ID
|
|
require.NoError(t, exe.db.Create(key).Error)
|
|
}
|
|
entry, err := locationEntry(location.ID, file.Name, info)
|
|
require.NoError(t, err)
|
|
item, err := selectedFileObservation(location, entry.Reference, info)
|
|
require.NoError(t, err)
|
|
items, ids = append(items, item), append(ids, file.ID)
|
|
}
|
|
queries := 0
|
|
require.NoError(t, exe.db.Callback().Query().After("gorm:query").Register("selection-batch-cost", func(*gorm.DB) { queries++ }))
|
|
staged, err := resource.OpenTemporaryDB(t.TempDir(), "stage-")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, staged.Close()) })
|
|
require.NoError(t, staged.DB.AutoMigrate(&observation.Item{}))
|
|
require.NoError(t, exe.stageSelectedFiles(ctx, staged.DB, location, items))
|
|
require.LessOrEqual(t, queries, 6, "association and tracking reads are bounded batches")
|
|
for _, item := range items {
|
|
require.Equal(t, []byte("opaque"), item.Signature)
|
|
require.Equal(t, hash[:], item.Hash)
|
|
}
|
|
|
|
// Capture uses the same observation-matching rule and preserves order and opaque signatures.
|
|
queries = 0
|
|
var captured []int64
|
|
require.NoError(t, exe.CaptureOriginals(ctx, append(ids, ids[len(ids)-1]+1), func(id int64, name string, expected *entity.ExpectedFile) error {
|
|
require.Equal(t, filepath.Join(root, fmt.Sprintf("file-%03d", len(captured))), name)
|
|
require.Equal(t, []byte("opaque"), expected.Signature)
|
|
require.Equal(t, hash[:], expected.Sha256)
|
|
captured = append(captured, id)
|
|
return nil
|
|
}))
|
|
require.Equal(t, ids, captured)
|
|
require.LessOrEqual(t, queries, 12, "unchanged admission cannot issue per-row catalog queries")
|
|
|
|
// Full physical preparation freezes the same identities and emits only identity and logical paths.
|
|
queries = 0
|
|
require.NoError(t, exe.stageLiveSelections(ctx, staged.DB, location.ID, []*entity.LocationSelection{{LocationId: location.ID}}))
|
|
var selected []int64
|
|
require.NoError(t, exe.yieldStagedSelections(ctx, staged.DB, func(file *library.File, target string) error {
|
|
require.Equal(t, file.Name, target)
|
|
require.Zero(t, file.Size)
|
|
require.Nil(t, file.ContentSummary)
|
|
selected = append(selected, file.ID)
|
|
return nil
|
|
}))
|
|
require.Equal(t, ids, selected)
|
|
require.LessOrEqual(t, queries, 90, "publication and final identity/path projection also batch their reads")
|
|
|
|
// Duplicate complete roots must not multiply observations before staging deduplication.
|
|
observed := 0
|
|
require.NoError(t, staged.DB.Callback().Create().Before("gorm:create").Register("selection-observation-count", func(tx *gorm.DB) {
|
|
conflict, _ := tx.Statement.Clauses["ON CONFLICT"].Expression.(clause.OnConflict)
|
|
rows, ok := tx.Statement.Dest.(*[]*observation.Item)
|
|
if ok && conflict.DoNothing {
|
|
observed += len(*rows)
|
|
require.LessOrEqual(t, len(*rows), observation.BatchSize)
|
|
}
|
|
}))
|
|
var roots []*entity.LocationSelection
|
|
for i := 0; i < 40; i++ {
|
|
roots = append(roots, &entity.LocationSelection{LocationId: location.ID})
|
|
}
|
|
roots = append(roots, &entity.LocationSelection{LocationId: location.ID, Path: "file-000"})
|
|
require.NoError(t, exe.stageLiveSelections(ctx, staged.DB, location.ID, roots))
|
|
require.Equal(t, len(ids)+1, observed, "one observation per File plus the root directory")
|
|
|
|
// Changed admissions also share transaction-local validation facts; actual row writes remain atomic.
|
|
matches := make([]*library.ObservationAdmission, 0, len(items))
|
|
for i, item := range items {
|
|
position := item.Position()
|
|
position.FileID, position.Size = ids[i], position.Size+1
|
|
matches = append(matches, &library.ObservationAdmission{Observation: position})
|
|
}
|
|
queries = 0
|
|
admitted, err := exe.lib.AdmitObservations(ctx, location.ID, matches)
|
|
require.NoError(t, err)
|
|
require.Len(t, admitted, len(items))
|
|
require.LessOrEqual(t, queries, 25, "changed admission cannot reload original/identity/native facts per File")
|
|
for _, original := range admitted {
|
|
require.EqualValues(t, 8, original.Size)
|
|
}
|
|
|
|
// Single-file missing identity remains typed, and cancellation cannot emit partial captures.
|
|
_, _, err = exe.CaptureOriginal(ctx, 0)
|
|
require.ErrorIs(t, err, library.ErrFileNotFound)
|
|
cancelled, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
err = exe.CaptureOriginals(cancelled, ids, func(int64, string, *entity.ExpectedFile) error {
|
|
t.Fatal("cancelled capture emitted a row")
|
|
return nil
|
|
})
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
}
|