Files
acp/item_test.go
Samuel CUI 159d8ea4ea Decouple bounded copy pipeline and preserve ordered completion
Overlap source reads, hashing, ordered linear writes, and asynchronous cleanup with bounded buffers. Preserve conflicting output order, API behavior, and no-space diagnostics. Add an LTFS drive-buffer model and reproducible performance checks.
2026-10-09 00:33:14 +08:00

1430 lines
45 KiB
Go

package acp
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/sirupsen/logrus"
)
// fixtureItem is a caller-owned item that records every terminal outcome, so a test can assert
// the result contract without a report or a sink. The engine reports results through onResults,
// and the fixture below dispatches each result to the item that produced it.
type fixtureItem struct {
source string
targets []string
// hook observes each terminal outcome before it is recorded.
hook func(*Result, error)
lock sync.Mutex
results []*Result
failures []error
}
func newFixtureItem(source string, targets ...string) *fixtureItem {
return &fixtureItem{source: source, targets: targets}
}
func (i *fixtureItem) Source() string { return i.source }
func (i *fixtureItem) Targets() []string { return i.targets }
// Completed records one completed item. It is a test helper, not part of Item.
func (i *fixtureItem) Completed(result *Result) { i.record(result, nil) }
// Failed records one item ACP could not process. It is a test helper, not part of Item.
func (i *fixtureItem) Failed(err error) { i.record(nil, err) }
func (i *fixtureItem) record(result *Result, err error) {
if i.hook != nil {
i.hook(result, err)
}
i.lock.Lock()
defer i.lock.Unlock()
if err != nil {
i.failures = append(i.failures, err)
return
}
i.results = append(i.results, result)
}
// terminal returns the item's single terminal outcome.
func (i *fixtureItem) terminal(t *testing.T) (*Result, error) {
t.Helper()
i.lock.Lock()
defer i.lock.Unlock()
if total := len(i.results) + len(i.failures); total != 1 {
t.Fatalf("item %q received %d terminal outcomes, want exactly 1", i.source, total)
}
if len(i.failures) == 1 {
return nil, i.failures[0]
}
return i.results[0], nil
}
// callbackCount returns how many terminal outcomes the item received.
func (i *fixtureItem) callbackCount() int {
i.lock.Lock()
defer i.lock.Unlock()
return len(i.results) + len(i.failures)
}
// callbackOrder records the terminal outcome order of several items.
type callbackOrder struct {
lock sync.Mutex
items []*fixtureItem
}
func (o *callbackOrder) record(item *fixtureItem) {
o.lock.Lock()
defer o.lock.Unlock()
o.items = append(o.items, item)
}
func (o *callbackOrder) snapshot() []*fixtureItem {
o.lock.Lock()
defer o.lock.Unlock()
return append([]*fixtureItem(nil), o.items...)
}
// streamFixture is the results callback of one test run: it records every delivered batch and
// dispatches each result to the fixture item that produced it, so a test keeps asserting an
// item's single terminal outcome.
type streamFixture struct {
lock sync.Mutex
items map[Item]*fixtureItem
batches [][]Result
// hook observes every batch before it is dispatched; an error it returns is the run's stop.
hook func([]Result) error
}
func newStreamFixture(items ...*fixtureItem) *streamFixture {
fixture := &streamFixture{items: make(map[Item]*fixtureItem, len(items))}
for _, item := range items {
fixture.items[item] = item
}
return fixture
}
func (f *streamFixture) onResults(results []Result) error {
f.lock.Lock()
f.batches = append(f.batches, append([]Result(nil), results...))
hook := f.hook
f.lock.Unlock()
if hook != nil {
if err := hook(results); err != nil {
return err
}
}
for _, result := range results {
f.lock.Lock()
item := f.items[result.Job]
f.lock.Unlock()
if item == nil {
continue
}
if result.Err != nil {
item.Failed(result.Err)
continue
}
completed := result
item.Completed(&completed)
}
return nil
}
// add registers more fixture items, which a producer that creates items while it feeds needs.
func (f *streamFixture) add(items ...*fixtureItem) {
f.lock.Lock()
defer f.lock.Unlock()
for _, item := range items {
f.items[item] = item
}
}
// batchesDelivered returns how many times the run called the results callback.
func (f *streamFixture) batchesDelivered() int {
f.lock.Lock()
defer f.lock.Unlock()
return len(f.batches)
}
// delivered returns every delivered result, in delivery order.
func (f *streamFixture) delivered() []Result {
f.lock.Lock()
defer f.lock.Unlock()
all := make([]Result, 0, len(f.batches))
for _, batch := range f.batches {
all = append(all, batch...)
}
return all
}
// runStream submits one batch of items, closes the run and returns its terminal error.
func runStream(ctx context.Context, onResults func([]Result) error, items []Item, opts ...Option) error {
stream, err := NewStream(ctx, onResults, opts...)
if err != nil {
return err
}
_ = stream.Submit(items...)
_ = stream.Close()
return stream.Wait()
}
// runFixture is runStream for a set of fixture items.
func runFixture(ctx context.Context, fixture *streamFixture, items []Item, opts ...Option) error {
return runStream(ctx, fixture.onResults, items, opts...)
}
// runShell drives one af05f05c shell run and returns its terminal error.
func runShell(ctx context.Context, opts ...Option) error {
copyer, err := New(ctx, opts...)
if err != nil {
return err
}
return copyer.WaitErr()
}
// feedBatches submits every batch in order and returns the first refusal, which is what a
// caller-owned feed sees when the run stops accepting work.
func feedBatches(stream *StreamCopyer, batches [][]Item) error {
for _, batch := range batches {
if err := stream.Submit(batch...); err != nil {
return err
}
}
return nil
}
// newTestStream builds the pipeline state a stage-level test needs without starting it.
func newTestStream(t *testing.T, opts ...Option) *StreamCopyer {
t.Helper()
option, err := buildOption(opts...)
if err != nil {
t.Fatalf("check options: %v", err)
}
device := t.TempDir()
return &StreamCopyer{
option: option,
ctx: context.Background(),
readCh: make(chan *baseJob, option.readBuffer),
resultCh: make(chan Result, option.resultBuffer),
eventCh: make(chan Event, 256),
hardStop: make(chan struct{}),
onResults: func([]Result) error { return nil },
getDevice: func(string) (string, error) { return device, nil },
availableSpace: availableSpace,
}
}
// runResults drives the reporting stage and the delivery stage of a stage-level test the way the
// pipeline does: the delivery stage drains the result buffer on its own goroutine until the
// reporting stage closed it. It returns once the caller's callback saw everything.
func runResults(copyer *StreamCopyer, copyed <-chan *baseJob) {
delivered := make(chan struct{})
go func() {
defer close(delivered)
copyer.deliver()
}()
copyer.report(copyed)
close(copyer.resultCh)
<-delivered
}
// cancelOnFirstResult stops the run from the results callback, which is where a caller observes
// that the pipeline is working.
func cancelOnFirstResult(cancel context.CancelFunc) func([]Result) error {
var once sync.Once
return func([]Result) error {
once.Do(cancel)
return nil
}
}
// writeSourceFile writes one source file below dir and returns its path.
func writeSourceFile(t *testing.T, dir, name string, content []byte) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create source directory: %v", err)
}
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatalf("write source file %q: %v", path, err)
}
return path
}
func TestRunCopiesItemsToLinearTarget(t *testing.T) {
// Build caller-owned items without constructing copy options per file.
root := t.TempDir()
order := new(callbackOrder)
items := make([]Item, 0, 3)
fixtures := make([]*fixtureItem, 0, 3)
for index, content := range []string{"first", "second", "third"} {
name := string(rune('a'+index)) + ".txt"
source := writeSourceFile(t, root, filepath.Join("source", name), []byte(content))
item := newFixtureItem(source, filepath.Join(root, "target", name))
item.hook = func(*Result, error) { order.record(item) }
items = append(items, item)
fixtures = append(fixtures, item)
}
// Keep the target serialized while allowing source preparation to complete in any order.
if err := runFixture(
context.Background(),
newStreamFixture(fixtures...),
items,
WithHashPolicy(HashRead),
SetToDevice(LinearDevice(true)),
); err != nil {
t.Fatal(err)
}
// Result order is unspecified, so the test asserts every item's own outcome and that a
// serialized target still received them all.
if reported := order.snapshot(); len(reported) != len(items) {
t.Fatalf("received %d callbacks, want %d", len(reported), len(items))
}
for _, item := range fixtures {
result, err := item.terminal(t)
if err != nil {
t.Fatalf("item %q failed: %v", item.source, err)
}
if len(result.Targets) != 1 || result.Targets[0].Err != nil || len(result.SHA256) == 0 {
t.Fatalf("unexpected result: %#v", result)
}
if result.Job != Item(item) {
t.Fatalf("result job = %#v, want the submitted item", result.Job)
}
}
}
func TestForwardPreparedPreservesRequiredOrder(t *testing.T) {
// Deliver preparation completions in a different order from their source requests.
newJob := func(order uint64, path string) *writeJob {
return newWriteJob(
&baseJob{order: order, path: path},
io.NopCloser(strings.NewReader("fixture")),
)
}
tests := []struct {
name string
fromLinear, toLinear bool
fromThreads, toThreads int
wantIDs []string
}{
{name: "linear target", toLinear: true, fromThreads: 3, toThreads: 1, wantIDs: []string{"1", "3"}},
{name: "linear source", fromLinear: true, fromThreads: 1, toThreads: 3, wantIDs: []string{"1", "3"}},
{name: "serial random devices", fromThreads: 1, toThreads: 1, wantIDs: []string{"1", "3"}},
{name: "parallel source serial target", fromThreads: 3, toThreads: 1, wantIDs: []string{"3", "1"}},
{name: "serial source parallel target", fromThreads: 1, toThreads: 3, wantIDs: []string{"3", "1"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Deliver later preparation results first; a failed or skipped middle request has no write Job.
copyer := &StreamCopyer{option: &option{
fromDevice: &deviceOption{threads: test.fromThreads, linear: test.fromLinear},
toDevice: &deviceOption{threads: test.toThreads, linear: test.toLinear},
}}
completed := make(chan prepareResult, 3)
completed <- prepareResult{order: 2, job: newJob(2, "3")}
completed <- prepareResult{order: 0, job: newJob(0, "1")}
completed <- prepareResult{order: 1}
close(completed)
prepared := make(chan *writeJob, 3)
// Preserve former serial behavior while concurrent random preparation may complete out of order.
copyer.forwardPrepared(completed, prepared)
close(prepared)
var ids []string
for job := range prepared {
ids = append(ids, job.path)
job.finishSource()
}
if len(ids) != len(test.wantIDs) {
t.Fatalf("prepared paths = %v, want %v", ids, test.wantIDs)
}
for index := range ids {
if ids[index] != test.wantIDs[index] {
t.Fatalf("prepared paths = %v, want %v", ids, test.wantIDs)
}
}
})
}
}
func TestRunHashesWithoutTargets(t *testing.T) {
// Submit one targetless item through the same batch interface.
content := []byte("hash-only fixture")
source := writeSourceFile(t, t.TempDir(), "source.txt", content)
item := newFixtureItem(source)
// Verify ACP reads the source once and reports its SHA-256 without creating a target.
if err := runFixture(context.Background(), newStreamFixture(item), []Item{item}, WithHashPolicy(HashRead)); err != nil {
t.Fatal(err)
}
result, err := item.terminal(t)
if err != nil {
t.Fatalf("item failed: %v", err)
}
wantHash := sha256.Sum256(content)
if got := hex.EncodeToString(result.SHA256); got != hex.EncodeToString(wantHash[:]) {
t.Fatalf("SHA256 = %q, want %q", got, hex.EncodeToString(wantHash[:]))
}
if len(result.Targets) != 0 {
t.Fatalf("hash-only targets = %v, want none", result.Targets)
}
if result.SignatureCacheHit {
t.Fatal("hash-only item reported a signature cache hit")
}
}
func TestStreamReportsSubmissionFailureAsRunError(t *testing.T) {
// A submission the run no longer accepts is a run error, and the items it did accept keep
// their own outcomes.
root := t.TempDir()
input := writeSourceFile(t, root, "source.txt", []byte("fixture"))
accepted := newFixtureItem(input, filepath.Join(root, "target.txt"))
refused := newFixtureItem(input, filepath.Join(root, "other.txt"))
fixture := newStreamFixture(accepted)
stream, err := NewStream(context.Background(), fixture.onResults, Overwrite(true))
if err != nil {
t.Fatal(err)
}
if err := stream.Submit(accepted); err != nil {
t.Fatalf("Submit() error = %v, want the first item accepted", err)
}
// Close ends the feed, so the run accepts no more work.
if err := stream.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
if err := stream.Submit(refused); err == nil {
t.Fatal("Submit() error = nil after Close, want a refusal")
}
result, terminalErr := accepted.terminal(t)
if terminalErr != nil {
t.Fatalf("item %q failed: %v", accepted.source, terminalErr)
}
if len(result.Targets) != 1 || result.Targets[0].Err != nil {
t.Fatalf("item %q targets = %v", accepted.source, result.Targets)
}
if err := stream.Wait(); err == nil {
t.Fatal("Wait() error = nil, want the refused submission")
}
if refused.callbackCount() != 0 {
t.Fatalf("the refused item received %d outcomes, want none", refused.callbackCount())
}
}
func TestRunRejectsReuseOnlyPolicyForItemsWithTargets(t *testing.T) {
// A copy always reads its source and produces a computed hash, so a value that promises a
// stored hash cannot describe it. The policy is rejected as an item error
// instead of silently reading.
for _, policy := range []HashPolicy{HashCachedOnly, HashCachedOrRead} {
t.Run(policy.String(), func(t *testing.T) {
root := t.TempDir()
input := writeSourceFile(t, root, "source.txt", []byte("fixture"))
target := filepath.Join(root, "target.txt")
item := newFixtureItem(input, target)
if err := runFixture(context.Background(), newStreamFixture(item), []Item{item}, WithHashPolicy(policy)); err != nil {
t.Fatalf("run error = %v, want nil: a rejected item is an item outcome", err)
}
if result, err := item.terminal(t); err == nil {
t.Fatalf("item completed with %#v, want the rejected policy", result)
} else if !strings.Contains(err.Error(), "check hash policy failed") ||
!strings.Contains(err.Error(), policy.String()) {
t.Fatalf("item error = %v, want a rejected %s policy", err, policy)
}
// The rejected item must not have touched the target.
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("stat target error = %v, want %v", err, os.ErrNotExist)
}
})
}
}
func TestRunKeepsEveryOtherPolicyForItemsWithTargets(t *testing.T) {
// The remaining values read content or need none, so a transfer can use all of them.
for _, policy := range []HashPolicy{HashOff, HashCachedOrReadRefresh, HashRead, HashReadRefresh} {
t.Run(policy.String(), func(t *testing.T) {
root := t.TempDir()
content := []byte("policy fixture")
input := writeSourceFile(t, root, "source.txt", content)
target := filepath.Join(root, "target.txt")
item := newFixtureItem(input, target)
if err := runFixture(context.Background(), newStreamFixture(item), []Item{item}, WithHashPolicy(policy)); err != nil {
t.Fatalf("run error = %v", err)
}
result, err := item.terminal(t)
if err != nil {
t.Fatalf("item failed: %v", err)
}
if len(result.Targets) != 1 || result.Targets[0].Err != nil || result.Targets[0].Path != target {
t.Fatalf("targets = %#v, want %q written", result.Targets, target)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, content) {
t.Fatalf("target content = %q, want %q", got, content)
}
// Only a reading policy produces the computed hash of the copied bytes.
wantHash := sha256.Sum256(content)
if policy.producesHash() {
if !bytes.Equal(result.SHA256, wantHash[:]) {
t.Fatalf("SHA256 = %x, want %x", result.SHA256, wantHash)
}
} else if len(result.SHA256) != 0 {
t.Fatalf("SHA256 = %x, want none", result.SHA256)
}
})
}
}
func TestRunReportsTargetFailureAsItemOutcome(t *testing.T) {
// Force target creation to fail while the item still completes.
root := t.TempDir()
input := writeSourceFile(t, root, "source.txt", []byte("fixture"))
target := writeSourceFile(t, root, "target.txt", []byte("existing"))
item := newFixtureItem(input, target)
// A failed target is an item outcome with its own error identity, not a run error.
if err := runFixture(context.Background(), newStreamFixture(item), []Item{item}); err != nil {
t.Fatalf("run error = %v, want nil", err)
}
result, err := item.terminal(t)
if err != nil {
t.Fatalf("item failed: %v", err)
}
if len(result.Targets) != 1 {
t.Fatalf("targets = %v, want one outcome", result.Targets)
}
if !errors.Is(result.Targets[0].Err, fs.ErrExist) {
t.Fatalf("target error = %v, want %v", result.Targets[0].Err, fs.ErrExist)
}
// The refused target must keep its previous content.
content, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(content, []byte("existing")) {
t.Fatalf("target content = %q, want %q", content, "existing")
}
}
func TestRunReportsOneOutcomePerRequestedTargetInOrder(t *testing.T) {
// Request two targets where only the second one can be written.
root := t.TempDir()
input := writeSourceFile(t, root, "source.txt", []byte("fixture"))
refused := writeSourceFile(t, root, "refused.txt", []byte("existing"))
written := filepath.Join(root, "written.txt")
item := newFixtureItem(input, refused, written)
if err := runFixture(context.Background(), newStreamFixture(item), []Item{item}); err != nil {
t.Fatalf("run error = %v, want nil", err)
}
result, err := item.terminal(t)
if err != nil {
t.Fatalf("an item with a failed target must still complete: %v", err)
}
if len(result.Targets) != 2 {
t.Fatalf("targets = %v, want one outcome per requested target", result.Targets)
}
if got := result.Targets[0]; got.Path != refused || !errors.Is(got.Err, fs.ErrExist) {
t.Fatalf("target 0 = %#v, want %q refused with %v", got, refused, fs.ErrExist)
}
if got := result.Targets[1]; got.Path != written || got.Err != nil {
t.Fatalf("target 1 = %#v, want %q written", got, written)
}
// The successful target must hold the source content.
content, err := os.ReadFile(written)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(content, []byte("fixture")) {
t.Fatalf("written content = %q, want %q", content, "fixture")
}
}
// TestRunCopiesAChunkedSourceToEveryTarget pins a successful multi-target copy larger than one
// read buffer: several chunks of the shared buffer pool travel to every target, and each target
// must hold the whole source. The content is a fixed byte pattern instead of a random source, so
// the test is reproducible, and every target hash is computed from the bytes read back from disk.
func TestRunCopiesAChunkedSourceToEveryTarget(t *testing.T) {
// Three full chunks plus a short final one, so the read loop, the buffer handoff and the
// final partial write are all exercised.
content := make([]byte, 3*batchSize+12345)
if len(content) <= batchSize {
t.Fatalf("test content = %d bytes, want more than one %d-byte chunk", len(content), batchSize)
}
for index := range content {
content[index] = byte(index*7 + index/251)
}
// Track the shared pool as well: several buffers pass through it, and every reference must
// come back by the time the run ends.
trackChunkPool(t)
root := t.TempDir()
input := writeSourceFile(t, root, "source.bin", content)
targets := []string{
filepath.Join(root, "target-a.bin"),
filepath.Join(root, "target-b.bin"),
filepath.Join(root, "target-c.bin"),
}
item := newFixtureItem(input, targets...)
if err := runFixture(context.Background(), newStreamFixture(item), []Item{item}, WithHashPolicy(HashRead)); err != nil {
t.Fatalf("run error = %v, want nil", err)
}
result, err := item.terminal(t)
if err != nil {
t.Fatalf("item failed: %v", err)
}
wantHash := sha256.Sum256(content)
if result.Size != int64(len(content)) {
t.Fatalf("result size = %d, want %d", result.Size, len(content))
}
if !bytes.Equal(result.SHA256, wantHash[:]) {
t.Fatalf("result SHA256 = %x, want %x", result.SHA256, wantHash)
}
if len(result.Targets) != len(targets) {
t.Fatalf("targets = %#v, want one outcome per requested target", result.Targets)
}
for index, target := range targets {
outcome := result.Targets[index]
if outcome.Path != target || outcome.Err != nil {
t.Fatalf("target %d = %#v, want %q written", index, outcome, target)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatalf("read target %q: %v", target, err)
}
if !bytes.Equal(got, content) {
t.Fatalf("target %q holds %d bytes that differ from the source content, want %d matching bytes", target, len(got), len(content))
}
if hash := sha256.Sum256(got); !bytes.Equal(hash[:], wantHash[:]) {
t.Fatalf("target %q SHA256 = %x, want %x", target, hash, wantHash)
}
}
}
func TestRunReportsUnprocessableItemsAsFailures(t *testing.T) {
root := t.TempDir()
input := writeSourceFile(t, root, "source.txt", []byte("fixture"))
directory := filepath.Join(root, "directory")
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
// A source that cannot be described, opened, or read never completes.
tests := []struct {
name string
source string
target []string
wantErr error
anyErr bool
}{
{name: "missing source", source: filepath.Join(root, "missing"), wantErr: fs.ErrNotExist},
{name: "directory source", source: directory, anyErr: true},
{name: "successful item", source: input, target: []string{filepath.Join(root, "target.txt")}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
item := newFixtureItem(test.source, test.target...)
if err := runFixture(context.Background(), newStreamFixture(item), []Item{item}); err != nil {
t.Fatalf("run error = %v, want nil", err)
}
result, err := item.terminal(t)
if test.wantErr != nil || test.anyErr {
if err == nil {
t.Fatalf("item completed with %#v, want a failure", result)
}
if test.wantErr != nil && !errors.Is(err, test.wantErr) {
t.Fatalf("item error = %v, want %v", err, test.wantErr)
}
return
}
if err != nil {
t.Fatalf("item failed: %v", err)
}
if submitted, ok := result.Job.(*fixtureItem); !ok || submitted.source != filepath.Clean(test.source) {
t.Fatalf("result job = %#v, want the submitted item", result.Job)
}
})
}
}
func TestPrepareReportsUnstartedItemsWithoutReadingThem(t *testing.T) {
tests := []struct {
name string
itemError error
want error
}{
{
name: "source rejected while indexing",
itemError: fmt.Errorf("get source stat failed, %w", fs.ErrNotExist),
want: fs.ErrNotExist,
},
{name: "pipeline stopped", want: context.Canceled},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
if test.itemError == nil {
cancel()
}
defer cancel()
// The source does not exist, so only a pipeline that still starts a read can
// report anything other than the failure this stage owes the caller.
item := newFixtureItem(filepath.Join(t.TempDir(), "removed"), filepath.Join(t.TempDir(), "target"))
copyer := newTestStream(t)
fixture := newStreamFixture(item)
copyer.onResults = fixture.onResults
job := &baseJob{
copyer: copyer,
item: item,
path: item.source,
order: 0,
itemError: test.itemError,
}
indexed := make(chan *baseJob, 1)
indexed <- job
close(indexed)
// Preparation forwards an item it must not read to the results stage, and that
// stage reports it exactly once.
prepared := copyer.prepare(ctx, indexed)
copyed := copyer.copy(ctx, prepared)
runResults(copyer, copyed)
for range copyed {
}
if _, err := item.terminal(t); !errors.Is(err, test.want) {
t.Fatalf("item error = %v, want %v", err, test.want)
}
})
}
}
func TestPrepareReadsNoContentWhenThePolicyNeedsNone(t *testing.T) {
tests := []struct {
name string
policy HashPolicy
// A policy that uses the cache reads its stored hash through the item's descriptor, so a
// source it cannot open is a cache miss with a warning instead of an item failure.
wantMisses int64
wantFailures int64
}{
{name: "off", policy: HashOff},
{name: "cached only without entry", policy: HashCachedOnly, wantMisses: 1, wantFailures: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
copyer := newTestStream(t, WithHashPolicy(test.policy))
if test.policy.usesCache() {
copyer.signatures = newSignatureCache()
}
// The source only has to be described: a policy that needs no content reads nothing
// from it, so a missing path cannot turn the item into a failure.
item := newFixtureItem(filepath.Join(t.TempDir(), "removed"))
fixture := newStreamFixture(item)
copyer.onResults = fixture.onResults
job := &baseJob{
copyer: copyer,
item: item,
path: item.source,
stat: &stat{size: 7, mode: 0o644},
order: 0,
}
indexed := make(chan *baseJob, 1)
indexed <- job
close(indexed)
var prepared []*writeJob
for write := range copyer.prepare(context.Background(), indexed) {
if !write.skipContent {
t.Fatal("prepare kept a content read for a policy that needs no content")
}
prepared = append(prepared, write)
}
if len(prepared) != 1 {
t.Fatalf("prepared jobs = %d, want 1", len(prepared))
}
if err := runWriteJob(copyer, prepared[0]); err != nil {
t.Fatalf("pipeline failed: %v", err)
}
result, err := item.terminal(t)
if err != nil {
t.Fatalf("item failed: %v", err)
}
if len(result.SHA256) != 0 || result.SignatureCacheHit {
t.Fatalf("result = %#v, want no hash and no cache hit", result)
}
if result.Size != 7 {
t.Fatalf("result size = %d, want the indexed size", result.Size)
}
// A cache read that cannot open its descriptor is a miss with a warning, and the item
// still completes without a hash.
if copyer.signatures == nil {
return
}
summary := copyer.signatures.snapshot()
if summary.Misses != test.wantMisses || summary.Failures != test.wantFailures {
t.Fatalf("cache summary = %#v, want misses=%d failures=%d", summary, test.wantMisses, test.wantFailures)
}
})
}
}
func TestRunKeepsLinearOrderingPastAnUnprocessedItem(t *testing.T) {
// A linear writer advances per request order, so an item that preparation never reads
// must still advance that order: otherwise the items behind it lose their callback.
root := t.TempDir()
missing := newFixtureItem(filepath.Join(root, "missing"))
goodSource := writeSourceFile(t, root, "good.txt", []byte("good"))
goodTarget := filepath.Join(root, "target", "good.txt")
good := newFixtureItem(goodSource, goodTarget)
if err := runFixture(
context.Background(),
newStreamFixture(missing, good),
[]Item{missing, good},
SetToDevice(LinearDevice(true)),
); err != nil {
t.Fatalf("run error = %v, want nil", err)
}
if _, err := missing.terminal(t); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("rejected item error = %v, want %v", err, fs.ErrNotExist)
}
result, err := good.terminal(t)
if err != nil {
t.Fatalf("item behind the rejected one failed: %v", err)
}
if len(result.Targets) != 1 || result.Targets[0].Err != nil || result.Targets[0].Path != goodTarget {
t.Fatalf("targets = %#v, want %q written", result.Targets, goodTarget)
}
}
func TestRunReportsEveryAcceptedItemExactlyOnce(t *testing.T) {
tests := []struct {
name string
opts []Option
}{
{name: "buffered target"},
{name: "linear target", opts: []Option{SetToDevice(LinearDevice(true))}},
// A linear source hands one reader to the copy stage at a time, so a stop that
// releases its wait instead of draining its input is where items used to disappear.
{name: "linear source", opts: []Option{SetFromDevice(LinearDevice(true))}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
const total = 16
items := make([]Item, 0, total)
fixtures := make([]*fixtureItem, 0, total)
for index := 0; index < total; index++ {
name := fmt.Sprintf("%02d.txt", index)
source := writeSourceFile(t, root, name, []byte(strings.Repeat(name, 64)))
item := newFixtureItem(source, filepath.Join(root, "target", name))
items = append(items, item)
fixtures = append(fixtures, item)
}
fixture := newStreamFixture(fixtures...)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// The read buffer is much smaller than the batch in hand, so the stop lands while
// the pipeline is still working through accepted items.
opts := []Option{WithReadBuffer(4), WithResultBuffer(1), WithResultBatch(1)}
opts = append(opts, test.opts...)
stream, err := NewStream(ctx, fixture.onResults, opts...)
if err != nil {
t.Fatal(err)
}
fixture.hook = cancelOnFirstResult(cancel)
// One batch is submitted completely even though the callback stopped the feed, and
// the run reports what it can still start.
if err := stream.Submit(items...); err != nil {
t.Fatalf("Submit() error = %v, want the batch in hand accepted", err)
}
_ = stream.Close()
// A stop the run observed is the run's error, while a pipeline that drained before
// the callback cancelled reports success, exactly as
// TestRunDoesNotReportAStopAfterACompleteRun pins. Which of the two happens depends
// on how far the pipeline got, so only the stopping reason is asserted here.
if err := stream.Wait(); err != nil && !errors.Is(err, context.Canceled) {
t.Fatalf("Wait() error = %v, want nil or %v", err, context.Canceled)
}
// The stop must not break the accounting: every accepted item owns exactly one
// terminal outcome, none of them is duplicated, and an item the stop reached is
// reported with the stopping reason instead of a completion. How many items are
// abandoned depends on how far the pipeline got before the callback cancelled, so
// the test asserts the accounting rather than a count: a fast run may finish every
// item it accepted.
completed := 0
for _, item := range fixtures {
if got := item.callbackCount(); got != 1 {
t.Fatalf("item %q received %d outcomes, want 1", item.source, got)
}
result, itemErr := item.terminal(t)
if itemErr != nil {
if !errors.Is(itemErr, context.Canceled) {
t.Fatalf("abandoned item %q error = %v, want %v", item.source, itemErr, context.Canceled)
}
continue
}
if len(result.Targets) != 1 || result.Targets[0].Err != nil {
t.Fatalf("completed item %q targets = %v", item.source, result.Targets)
}
if _, err := os.Stat(result.Targets[0].Path); err != nil {
t.Fatalf("completed item %q target %q: %v", item.source, result.Targets[0].Path, err)
}
completed++
}
// The delivery that cancelled the run carries the first result the pipeline produced,
// and a result produced before the stop cannot carry the stopping reason, so the run
// always completes at least the item whose delivery stopped it.
if completed == 0 {
t.Fatal("no item finished before the stop")
}
})
}
}
func TestRunDeliversResultsFromOneGoroutine(t *testing.T) {
// ACP delivers results one batch at a time and from a single goroutine, so a caller may
// keep unguarded state in its callback.
root := t.TempDir()
const total = 32
var (
lock sync.Mutex
goroutines = map[string]struct{}{}
callbackErr []error
overlapped int32
inFlight int32
)
items := make([]Item, 0, total)
fixtures := make([]*fixtureItem, 0, total)
for index := 0; index < total; index++ {
name := fmt.Sprintf("%02d.txt", index)
content := []byte(strings.Repeat(name, 32))
source := writeSourceFile(t, root, name, content)
item := newFixtureItem(source, filepath.Join(root, "target", name))
items = append(items, item)
fixtures = append(fixtures, item)
}
fixture := newStreamFixture(fixtures...)
fixture.hook = func(results []Result) error {
if atomic.AddInt32(&inFlight, 1) != 1 {
atomic.AddInt32(&overlapped, 1)
}
id := currentGoroutineID()
lock.Lock()
goroutines[id] = struct{}{}
lock.Unlock()
// The callback runs after its item is final, so this test reads a small file to prove
// it. A callback that does I/O only slows the pipeline down.
for _, result := range results {
for _, outcome := range result.Targets {
if outcome.Err != nil {
continue
}
got, readErr := os.ReadFile(outcome.Path)
if readErr != nil {
lock.Lock()
callbackErr = append(callbackErr, readErr)
lock.Unlock()
continue
}
if !bytes.Equal(got, contentOfFixture(fixtures, result.Job)) {
lock.Lock()
callbackErr = append(callbackErr, fmt.Errorf("target %q content = %q", outcome.Path, got))
lock.Unlock()
}
}
}
runtime.Gosched()
atomic.AddInt32(&inFlight, -1)
return nil
}
if err := runFixture(context.Background(), fixture, items, WithHashPolicy(HashRead), WithResultBuffer(1), WithResultBatch(1)); err != nil {
t.Fatal(err)
}
if overlapped != 0 {
t.Fatalf("%d callbacks overlapped another callback", overlapped)
}
lock.Lock()
defer lock.Unlock()
if len(goroutines) != 1 {
t.Fatalf("callbacks arrived from %d goroutines, want 1", len(goroutines))
}
if len(callbackErr) != 0 {
t.Fatalf("callback I/O failed: %v", callbackErr)
}
}
// contentOfFixture returns the source content one fixture item was built from.
func contentOfFixture(fixtures []*fixtureItem, job Item) []byte {
for _, item := range fixtures {
if Item(item) == job {
return []byte(strings.Repeat(filepath.Base(item.source), 32))
}
}
return nil
}
// TestRunCallsOneEventGoroutineAtATime pins the contract a handler may rely on: one registered
// handler receives events from one goroutine and never concurrently, which is what lets the
// progress bar keep its total count without a lock.
func TestRunCallsOneEventGoroutineAtATime(t *testing.T) {
// The contract a handler may rely on: one registered handler receives events from one
// goroutine and never concurrently, which is what lets the progress bar keep its total
// count without a lock.
root := t.TempDir()
const total = 8
items := make([]Item, 0, total)
fixtures := make([]*fixtureItem, 0, total)
for index := 0; index < total; index++ {
name := fmt.Sprintf("%02d.txt", index)
source := writeSourceFile(t, root, name, []byte(name))
item := newFixtureItem(source, filepath.Join(root, "target", name))
items = append(items, item)
fixtures = append(fixtures, item)
}
var (
inFlight int32
seen int
goroutine string
)
handler := func(Event) {
if !atomic.CompareAndSwapInt32(&inFlight, 0, 1) {
t.Error("the event handler was called concurrently")
}
// Unguarded handler state is the point: the contract is one event at a time.
seen++
if id := currentGoroutineID(); goroutine == "" {
goroutine = id
} else if id != goroutine {
t.Errorf("the event handler ran on goroutine %s, want %s", id, goroutine)
}
atomic.StoreInt32(&inFlight, 0)
}
if err := runFixture(
context.Background(),
newStreamFixture(fixtures...),
items,
WithHashPolicy(HashRead),
WithEventHandler(handler),
); err != nil {
t.Fatal(err)
}
if seen == 0 {
t.Fatal("the event handler received no event")
}
}
func TestRunAppliesBoundedBackpressure(t *testing.T) {
root := t.TempDir()
input := writeSourceFile(t, root, "source.txt", nil)
const (
total = int64(2048)
readBuffer = 8
consumedMax = total / 2
)
var consumed, produced, maximum int64
items := make([]*fixtureItem, 0, total)
for index := int64(0); index < total; index++ {
item := newFixtureItem(input, filepath.Join(root, "target", fmt.Sprintf("%04d", index)))
item.hook = func(*Result, error) { atomic.AddInt64(&consumed, 1) }
items = append(items, item)
}
// A bounded read buffer must stop the producer from submitting the whole input up front.
stream, err := NewStream(
context.Background(),
newStreamFixture(items...).onResults,
WithReadBuffer(readBuffer),
WithResultBuffer(1), WithResultBatch(1),
SetToDevice(LinearDevice(true)),
)
if err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() {
defer close(done)
for _, item := range items {
if err := stream.Submit(item); err != nil {
break
}
id := atomic.AddInt64(&produced, 1)
outstanding := id - atomic.LoadInt64(&consumed)
for {
previous := atomic.LoadInt64(&maximum)
if outstanding <= previous || atomic.CompareAndSwapInt64(&maximum, previous, outstanding) {
break
}
}
}
_ = stream.Close()
}()
// The producer owns the feed, so it closes the stream before the run can be waited for.
<-done
if err := stream.Wait(); err != nil {
t.Fatal(err)
}
if got := atomic.LoadInt64(&consumed); got != total {
t.Fatalf("consumed %d items, want %d", got, total)
}
if got := atomic.LoadInt64(&maximum); got >= consumedMax {
t.Fatalf("maximum outstanding items = %d, want less than %d", got, consumedMax)
}
}
func TestRunStopsFeedingItemsAfterGracefulStop(t *testing.T) {
// The first delivered result stops the caller's feed, and the feed learns it from Submit.
root := t.TempDir()
input := writeSourceFile(t, root, "source.txt", []byte("fixture"))
item := newFixtureItem(input, filepath.Join(root, "target.txt"))
fixture := newStreamFixture(item)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stream, err := NewStream(ctx, fixture.onResults, WithResultBuffer(1), WithResultBatch(1))
if err != nil {
t.Fatal(err)
}
fixture.hook = cancelOnFirstResult(cancel)
done := make(chan error, 1)
go func() {
if err := stream.Submit(item); err != nil {
done <- err
return
}
<-ctx.Done()
done <- stream.Submit(newFixtureItem(input, filepath.Join(root, "other.txt")))
}()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Submit() error = %v, want %v", err, context.Canceled)
}
case <-time.After(5 * time.Second):
t.Fatal("the run did not release the feed after the caller cancelled")
}
_ = stream.Close()
if err := stream.Wait(); !errors.Is(err, context.Canceled) {
t.Fatalf("Wait() error = %v, want %v", err, context.Canceled)
}
if _, err := item.terminal(t); err != nil {
t.Fatalf("item failed: %v", err)
}
}
func TestRunDoesNotReportAStopAfterACompleteRun(t *testing.T) {
// A context canceled after the feed ended and the pipeline drained is not a run error: the
// stop must be something the run actually observed.
root := t.TempDir()
input := writeSourceFile(t, root, "source.txt", []byte("fixture"))
item := newFixtureItem(input, filepath.Join(root, "target.txt"))
fixture := newStreamFixture(item)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
fixture.hook = func([]Result) error {
cancel()
return nil
}
if err := runFixture(ctx, fixture, []Item{item}, WithResultBuffer(1), WithResultBatch(1)); err != nil {
t.Fatalf("run error = %v, want nil after a complete run", err)
}
if _, err := item.terminal(t); err != nil {
t.Fatalf("item failed: %v", err)
}
}
func TestRunCancellationDrainsPrefetchedItems(t *testing.T) {
// Feed work until the pipeline holds prepared content, then cancel with buffers still queued.
trackChunkPool(t)
root := t.TempDir()
input := writeSourceFile(t, root, "source.txt", []byte("fixture"))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var (
lock sync.Mutex
produced []*fixtureItem
)
fixture := newStreamFixture()
stream, err := NewStream(ctx, fixture.onResults, SetToDevice(LinearDevice(true)),
WithHashPolicy(HashReadRefresh), WithResultBuffer(1), WithResultBatch(1))
if err != nil {
t.Fatal(err)
}
fixture.hook = cancelOnFirstResult(cancel)
// The producer stops at the feed checkpoint, exactly like a caller that watches the run.
done := make(chan struct{})
go func() {
defer close(done)
for index := 0; ; index++ {
item := newFixtureItem(input, filepath.Join(root, "target", fmt.Sprintf("%04d", index)))
if err := stream.Submit(item); err != nil {
return
}
lock.Lock()
produced = append(produced, item)
lock.Unlock()
fixture.add(item)
}
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("the feed did not stop after cancellation")
}
_ = stream.Close()
// Every produced item must report exactly one outcome.
lock.Lock()
items := append([]*fixtureItem(nil), produced...)
lock.Unlock()
if len(items) == 0 {
t.Fatal("the feed produced no items")
}
for _, item := range items {
if got := item.callbackCount(); got != 1 {
t.Fatalf("item %q received %d outcomes, want 1", item.source, got)
}
}
}
// currentGoroutineID reads the runtime's goroutine identifier so a test can prove that
// callbacks arrive from one goroutine. Production code must never depend on it.
func currentGoroutineID() string {
buf := make([]byte, 64)
n := runtime.Stack(buf, false)
fields := strings.Fields(string(buf[:n]))
if len(fields) < 2 {
return ""
}
return fields[1]
}
// lockedBuffer collects log output from several goroutines.
type lockedBuffer struct {
lock sync.Mutex
buf bytes.Buffer
}
func (b *lockedBuffer) Write(data []byte) (int, error) {
b.lock.Lock()
defer b.lock.Unlock()
return b.buf.Write(data)
}
func (b *lockedBuffer) String() string {
b.lock.Lock()
defer b.lock.Unlock()
return b.buf.String()
}
// captureLogs redirects the standard logger into the returned buffer.
func captureLogs(t *testing.T) *lockedBuffer {
t.Helper()
logs := new(lockedBuffer)
previous := logrus.StandardLogger().Out
logrus.SetOutput(logs)
t.Cleanup(func() { logrus.SetOutput(previous) })
return logs
}
func TestRunReturnsErrorWhenTheResultsCallbackPanics(t *testing.T) {
// A panic in caller-owned callback code must become a run error instead of unwinding the
// reporting goroutine, which would silently drop the accounting of every other item.
tests := []struct {
name string
// broken marks the panicking item as one ACP cannot process, so its failure is the
// result that panics.
broken bool
}{
{name: "completed item"},
{name: "failed item", broken: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
logs := captureLogs(t)
root := t.TempDir()
const total = 4
items := make([]Item, 0, total)
all := make([]*fixtureItem, 0, total)
fixtures := make([]*fixtureItem, 0, total)
var panicking *fixtureItem
for index := 0; index < total; index++ {
name := fmt.Sprintf("%02d.txt", index)
source := writeSourceFile(t, root, name, []byte(name))
if test.broken && index == 1 {
source = filepath.Join(root, "removed.txt")
}
item := newFixtureItem(source, filepath.Join(root, "target", name))
if index == 1 {
panicking = item
} else {
fixtures = append(fixtures, item)
}
all = append(all, item)
items = append(items, item)
}
fixture := newStreamFixture(all...)
fixture.hook = func(results []Result) error {
for _, result := range results {
if result.Job == Item(panicking) {
panic("callback fixture")
}
}
return nil
}
// One result per batch, so a panicking callback only loses the item it panicked on.
err := runFixture(context.Background(), fixture, items, WithResultBuffer(1), WithResultBatch(1))
if err == nil {
t.Fatal("run error = nil, want the callback panic reported")
}
if !strings.Contains(err.Error(), "callback fixture") {
t.Fatalf("run error = %v, want the panicking callback", err)
}
// The panic must not take the accounting of the other items with it.
for _, item := range fixtures {
item.terminal(t)
}
if panicking.callbackCount() != 0 {
t.Fatalf("panicking item recorded %d outcomes, want none", panicking.callbackCount())
}
if strings.Contains(logs.String(), "send on closed channel") {
t.Fatalf("the pipeline published on a closed channel:\n%s", logs.String())
}
})
}
}
func TestRunReturnsErrorWhenAnEventHandlerPanics(t *testing.T) {
logs := captureLogs(t)
root := t.TempDir()
const total = 4
items := make([]Item, 0, total)
fixtures := make([]*fixtureItem, 0, total)
for index := 0; index < total; index++ {
name := fmt.Sprintf("%02d.txt", index)
source := writeSourceFile(t, root, name, []byte(name))
item := newFixtureItem(source, filepath.Join(root, "target", name))
items = append(items, item)
fixtures = append(fixtures, item)
}
// A broken handler must fail the run and must not stop the remaining items.
handler := func(event Event) {
if _, ok := event.(*EventUpdateCount); ok {
panic("handler fixture")
}
}
err := runFixture(context.Background(), newStreamFixture(fixtures...), items, WithEventHandler(handler))
if err == nil {
t.Fatal("run error = nil, want the handler panic reported")
}
if !strings.Contains(err.Error(), "handler fixture") {
t.Fatalf("run error = %v, want the panicking handler", err)
}
// Every item still reports exactly one outcome.
for _, item := range fixtures {
item.terminal(t)
}
if strings.Contains(logs.String(), "send on closed channel") {
t.Fatalf("the pipeline published on a closed channel:\n%s", logs.String())
}
}