mirror of
https://github.com/samuelncui/acp.git
synced 2026-11-08 04:21:16 +00:00
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.
1430 lines
45 KiB
Go
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())
|
|
}
|
|
}
|