mirror of
https://github.com/samuelncui/acp.git
synced 2026-11-08 04:21:16 +00:00
Support per-item source read modes and preserve source compatibility. Fix submission shutdown and accepted-item totals. Honor CLI index-only operation and report-write failure exit status.
975 lines
32 KiB
Go
975 lines
32 KiB
Go
package acp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// collectResults returns a results callback that records every delivered batch in order.
|
|
func collectResults() (func([]Result) error, func() [][]Result) {
|
|
var (
|
|
lock sync.Mutex
|
|
batches [][]Result
|
|
)
|
|
callback := func(results []Result) error {
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
batches = append(batches, append([]Result(nil), results...))
|
|
return nil
|
|
}
|
|
snapshot := func() [][]Result {
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
return append([][]Result(nil), batches...)
|
|
}
|
|
return callback, snapshot
|
|
}
|
|
|
|
type readModeItem struct {
|
|
*SimpleJob
|
|
mode ReadMode
|
|
panicErr error
|
|
calls int
|
|
}
|
|
|
|
func (i *readModeItem) ReadMode() ReadMode {
|
|
i.calls++
|
|
if i.panicErr != nil {
|
|
panic(i.panicErr)
|
|
}
|
|
return i.mode
|
|
}
|
|
|
|
func TestItemsOverrideTheStreamReadMode(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
streamMode ReadMode
|
|
withOption bool
|
|
overrides []ReadMode
|
|
}{
|
|
{name: "implicit buffered default", overrides: []ReadMode{ReadMapped, ReadBuffered}},
|
|
{name: "mapped stream default", streamMode: ReadMapped, withOption: true, overrides: []ReadMode{ReadBuffered, ReadMapped}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
plain := &SimpleJob{Path: writeSourceFile(t, root, "plain", []byte("plain")), Dsts: []string{filepath.Join(root, "plain-copy")}}
|
|
first := &readModeItem{SimpleJob: &SimpleJob{Path: writeSourceFile(t, root, "first", []byte("first")), Dsts: []string{filepath.Join(root, "first-copy")}}, mode: tc.overrides[0]}
|
|
second := &readModeItem{SimpleJob: &SimpleJob{Path: writeSourceFile(t, root, "second", []byte("second")), Dsts: []string{filepath.Join(root, "second-copy")}}, mode: tc.overrides[1]}
|
|
items := []Item{plain, first, second}
|
|
|
|
// Observe the mode ACP actually passes to its source opener, including the item that
|
|
// has no override. Source preparation may run concurrently.
|
|
previous := openSourceContent
|
|
var lock sync.Mutex
|
|
opened := make(map[string]ReadMode)
|
|
openSourceContent = func(path string, mode ReadMode) (itemSource, error) {
|
|
lock.Lock()
|
|
opened[path] = mode
|
|
lock.Unlock()
|
|
return previous(path, mode)
|
|
}
|
|
t.Cleanup(func() { openSourceContent = previous })
|
|
|
|
callback, batches := collectResults()
|
|
opts := []Option{WithHashPolicy(HashReadRefresh)}
|
|
if tc.withOption {
|
|
opts = append(opts, SetFromDevice(WithReadMode(tc.streamMode)))
|
|
}
|
|
if err := runStream(context.Background(), callback, items, opts...); err != nil {
|
|
t.Fatalf("run error = %v", err)
|
|
}
|
|
|
|
wantModes := map[string]ReadMode{plain.Path: tc.streamMode, first.Path: first.mode, second.Path: second.mode}
|
|
lock.Lock()
|
|
if !reflect.DeepEqual(opened, wantModes) {
|
|
t.Errorf("source modes = %v, want %v", opened, wantModes)
|
|
}
|
|
lock.Unlock()
|
|
if first.calls != 1 || second.calls != 1 {
|
|
t.Errorf("override calls = %d/%d, want one each", first.calls, second.calls)
|
|
}
|
|
results := make(map[Item]Result)
|
|
for _, batch := range batches() {
|
|
for _, result := range batch {
|
|
results[result.Job] = result
|
|
}
|
|
}
|
|
if len(results) != len(items) {
|
|
t.Fatalf("results = %d, want %d", len(results), len(items))
|
|
}
|
|
for _, item := range items {
|
|
result := results[item]
|
|
if result.Err != nil || len(result.Targets) != 1 || result.Targets[0].Err != nil {
|
|
t.Fatalf("item %q result = %#v", item.Source(), result)
|
|
}
|
|
got, err := os.ReadFile(item.Targets()[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want, err := os.ReadFile(item.Source())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(got, want) {
|
|
t.Errorf("target %q = %q, want %q", item.Targets()[0], got, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestItemReadModeFailureDoesNotStopTheRun(t *testing.T) {
|
|
root := t.TempDir()
|
|
panicCause := errors.New("read mode fixture")
|
|
panicking := &readModeItem{SimpleJob: &SimpleJob{Path: writeSourceFile(t, root, "panic", []byte("panic"))}, panicErr: panicCause}
|
|
invalid := &readModeItem{SimpleJob: &SimpleJob{Path: writeSourceFile(t, root, "invalid", []byte("invalid"))}, mode: ReadMode(99)}
|
|
good := &SimpleJob{Path: writeSourceFile(t, root, "good", []byte("good"))}
|
|
callback, batches := collectResults()
|
|
if err := runStream(context.Background(), callback, []Item{panicking, invalid, good}, WithHashPolicy(HashRead)); err != nil {
|
|
t.Fatalf("run error = %v, want item failures only", err)
|
|
}
|
|
|
|
results := make(map[Item]Result)
|
|
for _, batch := range batches() {
|
|
for _, result := range batch {
|
|
if _, exists := results[result.Job]; exists {
|
|
t.Fatalf("item %q was reported twice", result.Job.Source())
|
|
}
|
|
results[result.Job] = result
|
|
}
|
|
}
|
|
if len(results) != 3 {
|
|
t.Fatalf("results = %d, want 3", len(results))
|
|
}
|
|
if err := results[panicking].Err; !errors.Is(err, panicCause) || !strings.Contains(err.Error(), "Item.ReadMode panicked") {
|
|
t.Errorf("panicking item error = %v, want protected caller panic", err)
|
|
}
|
|
if err := results[invalid].Err; err == nil || !strings.Contains(err.Error(), "unknown item read mode") {
|
|
t.Errorf("invalid item error = %v, want invalid mode", err)
|
|
}
|
|
if result := results[good]; result.Err != nil || len(result.SHA256) != sha256.Size {
|
|
t.Errorf("following item result = %#v, want a complete hash", result)
|
|
}
|
|
if panicking.calls != 1 || invalid.calls != 1 {
|
|
t.Errorf("override calls = %d/%d, want one each", panicking.calls, invalid.calls)
|
|
}
|
|
}
|
|
|
|
// TestFailedResultBypassesTheResultBuffer pins error rule 1: a result that carries an error is
|
|
// delivered immediately instead of waiting for a batch or the flush interval, so a caller can
|
|
// persist it before it submits the next batch. Both error outlets count: the item's own error and
|
|
// a requested target that was not written.
|
|
func TestFailedResultBypassesTheResultBuffer(t *testing.T) {
|
|
root := t.TempDir()
|
|
good := newFixtureItem(
|
|
writeSourceFile(t, root, "good.txt", []byte("fixture")),
|
|
filepath.Join(root, "good-target.txt"),
|
|
)
|
|
missing := newFixtureItem(filepath.Join(root, "missing.txt"), filepath.Join(root, "missing-target.txt"))
|
|
failedTarget := newFixtureItem(
|
|
writeSourceFile(t, root, "failed-target.txt", []byte("fixture")),
|
|
// A regular file in the way of the target directory, so the item completes with a
|
|
// failed target and no item-level error.
|
|
filepath.Join(writeSourceFile(t, root, "not-a-directory", []byte("fixture")), "failed-target.txt"),
|
|
)
|
|
|
|
delivered := make(chan []Result, 4)
|
|
// An interval long enough that only the rules under test can deliver a success.
|
|
stream, err := NewStream(context.Background(), func(results []Result) error {
|
|
delivered <- append([]Result(nil), results...)
|
|
return nil
|
|
}, WithResultFlushInterval(time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := stream.Submit(good, missing, failedTarget); err != nil {
|
|
t.Fatalf("Submit() error = %v", err)
|
|
}
|
|
|
|
// Both failures arrive as their own batch, in whatever order the pipeline completed them.
|
|
for _, want := range []Item{missing, failedTarget} {
|
|
select {
|
|
case results := <-delivered:
|
|
if len(results) != 1 || results[0].Job != want {
|
|
t.Fatalf("delivered %#v, want %#v alone", results, want)
|
|
}
|
|
if want == Item(missing) && results[0].Err == nil {
|
|
t.Fatalf("delivered %#v, want the item error", results)
|
|
}
|
|
if want == Item(failedTarget) {
|
|
if results[0].Err != nil {
|
|
t.Fatalf("delivered %#v, want a completed item with a failed target", results)
|
|
}
|
|
if len(results[0].Targets) != 1 || results[0].Targets[0].Err == nil {
|
|
t.Fatalf("delivered %#v, want the failed target outcome", results)
|
|
}
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatalf("a failed result (%#v) waited for the result buffer", want)
|
|
}
|
|
}
|
|
|
|
// The successful result is still buffered at this point.
|
|
select {
|
|
case results := <-delivered:
|
|
t.Fatalf("a successful result was delivered before the buffer filled: %#v", results)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
if err := stream.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
select {
|
|
case results := <-delivered:
|
|
if len(results) != 1 || results[0].Job != Item(good) || results[0].Err != nil {
|
|
t.Fatalf("delivered %#v, want the successful item", results)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Close did not flush the buffered success")
|
|
}
|
|
}
|
|
|
|
// TestSuccessResultsAreBatchedAndFlushedOnClose pins error rule 2 for successes: they are
|
|
// buffered into batches, delivered when a batch fills, and flushed by Close.
|
|
func TestSuccessResultsAreBatchedAndFlushedOnClose(t *testing.T) {
|
|
t.Run("buffered until close", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
const total = 8
|
|
|
|
items := make([]Item, 0, total)
|
|
for index := 0; index < total; index++ {
|
|
name := fmt.Sprintf("%02d.txt", index)
|
|
source := writeSourceFile(t, root, name, []byte(name))
|
|
items = append(items, newFixtureItem(source, filepath.Join(root, "target", name)))
|
|
}
|
|
|
|
callback, batches := collectResults()
|
|
stream, err := NewStream(
|
|
context.Background(),
|
|
callback,
|
|
// One slot and one batch slot more than the run produces, so only Close can
|
|
// deliver a success here.
|
|
WithResultBuffer(total+1),
|
|
WithResultBatch(total+1),
|
|
WithResultFlushInterval(time.Hour),
|
|
Overwrite(true),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := stream.Submit(items...); err != nil {
|
|
t.Fatalf("Submit() error = %v", err)
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
if got := batches(); len(got) != 0 {
|
|
t.Fatalf("delivered %d batches before Close, want none", len(got))
|
|
}
|
|
|
|
// Close is the only delivery point a run of successful items has here.
|
|
if err := stream.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
got := batches()
|
|
if len(got) != 1 {
|
|
t.Fatalf("delivered %d batches, want one", len(got))
|
|
}
|
|
if len(got[0]) != total {
|
|
t.Fatalf("delivered %d results, want %d", len(got[0]), total)
|
|
}
|
|
})
|
|
|
|
t.Run("delivered when the batch fills", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
const (
|
|
total = 8
|
|
// The batch is what decides delivery, while the result buffer is only the depth
|
|
// of the queue behind it.
|
|
batch = 2
|
|
resultBuffer = 1
|
|
)
|
|
|
|
items := make([]Item, 0, total)
|
|
for index := 0; index < total; index++ {
|
|
name := fmt.Sprintf("%02d.txt", index)
|
|
source := writeSourceFile(t, root, name, []byte(name))
|
|
items = append(items, newFixtureItem(source, filepath.Join(root, "target", name)))
|
|
}
|
|
|
|
delivered := make(chan []Result, total)
|
|
stream, err := NewStream(context.Background(), func(results []Result) error {
|
|
delivered <- append([]Result(nil), results...)
|
|
return nil
|
|
}, WithResultBuffer(resultBuffer), WithResultBatch(batch), WithResultFlushInterval(time.Hour), Overwrite(true))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := stream.Submit(items...); err != nil {
|
|
t.Fatalf("Submit() error = %v", err)
|
|
}
|
|
|
|
select {
|
|
case results := <-delivered:
|
|
if len(results) != batch {
|
|
t.Fatalf("first batch = %d results, want %d", len(results), batch)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the result batch never filled")
|
|
}
|
|
if err := stream.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestResultsCallbackErrorActsAsACancellation pins error rule 3: an error returned by the
|
|
// results callback stops the feed, reports the items it had not started with that error, keeps
|
|
// the items already past the read stage, and is the run's terminal error.
|
|
func TestResultsCallbackErrorActsAsACancellation(t *testing.T) {
|
|
root := t.TempDir()
|
|
const total = 24
|
|
|
|
items := make([]Item, 0, total)
|
|
for index := 0; index < total; index++ {
|
|
name := fmt.Sprintf("%02d.txt", index)
|
|
source := writeSourceFile(t, root, name, []byte(strings.Repeat(name, 64)))
|
|
items = append(items, newFixtureItem(source, filepath.Join(root, "target", name)))
|
|
}
|
|
|
|
stopErr := errors.New("results sink failed")
|
|
var (
|
|
lock sync.Mutex
|
|
delivered []Result
|
|
calls int
|
|
)
|
|
onResults := func(results []Result) error {
|
|
lock.Lock()
|
|
delivered = append(delivered, results...)
|
|
calls++
|
|
fail := calls >= 2
|
|
lock.Unlock()
|
|
|
|
if fail {
|
|
return stopErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// One writer serializes the items, so a stop still finds work inside the read pipeline, and
|
|
// one result per delivery lets the callback fail on its second call.
|
|
stream, err := NewStream(context.Background(), onResults, WithResultBuffer(1), WithResultBatch(1), SetToDevice(DeviceThreads(1)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The batch in hand is submitted completely; the next submission is refused once the
|
|
// callback error has become the run's stop.
|
|
if err := stream.Submit(items...); err != nil {
|
|
t.Fatalf("Submit() error = %v, want the batch in hand accepted", err)
|
|
}
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for stream.callbackStop() == nil {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the callback error never became the run's stop")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if err := stream.Submit(newFixtureItem(items[0].(*fixtureItem).source)); err == nil {
|
|
t.Fatal("Submit() error = nil, want a refusal after the callback failed")
|
|
}
|
|
// Close reports the flush error, which is the callback failure that stopped the run.
|
|
if err := stream.Close(); !errors.Is(err, stopErr) {
|
|
t.Fatalf("Close() error = %v, want %v", err, stopErr)
|
|
}
|
|
if err := stream.Wait(); !errors.Is(err, stopErr) {
|
|
t.Fatalf("Wait() error = %v, want %v", err, stopErr)
|
|
}
|
|
|
|
// Every accepted item is reported exactly once, either with the facts of a finished item
|
|
// or with the callback error that stopped the run.
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
outcomes := make(map[Item]Result, len(delivered))
|
|
for _, result := range delivered {
|
|
if _, reported := outcomes[result.Job]; reported {
|
|
t.Fatalf("item %v was reported more than once", result.Job)
|
|
}
|
|
outcomes[result.Job] = result
|
|
}
|
|
if len(outcomes) != total {
|
|
t.Fatalf("reported items = %d, want %d", len(outcomes), total)
|
|
}
|
|
|
|
completed, cancelled := 0, 0
|
|
for _, item := range items {
|
|
result, ok := outcomes[item]
|
|
if !ok {
|
|
t.Fatalf("item %q received no result", item.(*fixtureItem).source)
|
|
}
|
|
if result.Err != nil {
|
|
if !errors.Is(result.Err, stopErr) {
|
|
t.Fatalf("cancelled item %v error = %v, want %v", item, result.Err, stopErr)
|
|
}
|
|
cancelled++
|
|
continue
|
|
}
|
|
if len(result.Targets) != 1 || result.Targets[0].Err != nil {
|
|
t.Fatalf("finished item %v targets = %v", item, result.Targets)
|
|
}
|
|
completed++
|
|
}
|
|
if completed == 0 {
|
|
t.Fatal("no item past the read stage delivered its result")
|
|
}
|
|
if cancelled == 0 {
|
|
t.Fatal("no item inside the read pipeline was reported as cancelled")
|
|
}
|
|
}
|
|
|
|
// TestSubmitBlocksWhileTheReadBufferIsFull pins the feed's backpressure: Submit blocks until the
|
|
// read buffer has room instead of growing without bound.
|
|
func TestSubmitBlocksWhileTheReadBufferIsFull(t *testing.T) {
|
|
root := t.TempDir()
|
|
input := writeSourceFile(t, root, "source.txt", []byte("fixture"))
|
|
copyer := newTestStream(t, WithReadBuffer(1))
|
|
|
|
if err := copyer.Submit(newFixtureItem(input)); err != nil {
|
|
t.Fatalf("Submit() error = %v, want the first item accepted", err)
|
|
}
|
|
|
|
blocked := make(chan error, 1)
|
|
go func() { blocked <- copyer.Submit(newFixtureItem(input, filepath.Join(root, "target.txt"))) }()
|
|
select {
|
|
case err := <-blocked:
|
|
t.Fatalf("Submit() returned %v while the read buffer was full", err)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
// Draining one item releases the blocked submission.
|
|
<-copyer.readCh
|
|
select {
|
|
case err := <-blocked:
|
|
if err != nil {
|
|
t.Fatalf("Submit() error = %v after the read buffer drained", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Submit stayed blocked after the read buffer drained")
|
|
}
|
|
}
|
|
|
|
// TestWrapStopsAPanickingPipeline pins the fatal-panic boundary: a panic that escapes pipeline
|
|
// code ends the run, so a blocked handoff can never leave Wait waiting for a stage that died.
|
|
func TestWrapStopsAPanickingPipeline(t *testing.T) {
|
|
logs := captureLogs(t)
|
|
root := t.TempDir()
|
|
input := writeSourceFile(t, root, "source.txt", []byte("fixture"))
|
|
copyer := newTestStream(t, WithReadBuffer(1))
|
|
|
|
// Fill the read buffer, so the next submission blocks with no consumer to release it.
|
|
if err := copyer.Submit(newFixtureItem(input)); err != nil {
|
|
t.Fatalf("Submit() error = %v", err)
|
|
}
|
|
blocked := make(chan error, 1)
|
|
go func() { blocked <- copyer.Submit(newFixtureItem(input)) }()
|
|
|
|
copyer.wrap(context.Background(), func() { panic("worker fixture") })
|
|
|
|
select {
|
|
case err := <-blocked:
|
|
if !errors.Is(err, errStreamStopped) {
|
|
t.Fatalf("Submit() error = %v, want %v", err, errStreamStopped)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Submit stayed blocked after a pipeline panic")
|
|
}
|
|
if err := copyer.Wait(); err == nil || !strings.Contains(err.Error(), "worker fixture") {
|
|
t.Fatalf("Wait() error = %v, want the pipeline panic", err)
|
|
}
|
|
if logs.String() == "" {
|
|
t.Fatal("the panic was not logged")
|
|
}
|
|
}
|
|
|
|
// TestShellCopiesAnAccurateJob pins the af05f05c shell end to end: New plus AccurateJob copies
|
|
// one exact source to one exact target and publishes a terminal report row for it.
|
|
func TestShellCopiesAnAccurateJob(t *testing.T) {
|
|
root := t.TempDir()
|
|
content := []byte("accurate job fixture")
|
|
source := writeSourceFile(t, root, "source.txt", content)
|
|
|
|
t.Run("written target", func(t *testing.T) {
|
|
target := filepath.Join(root, "renamed.txt")
|
|
handler, getter := NewReportGetter()
|
|
|
|
copyer, err := New(
|
|
context.Background(),
|
|
AccurateJob(source, []string{target}),
|
|
WithHash(true),
|
|
WithEventHandler(handler),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
if err := copyer.WaitErr(); err != nil {
|
|
t.Fatalf("WaitErr() error = %v, want nil", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
report := getter()
|
|
if len(report.Errors) != 0 {
|
|
t.Fatalf("report errors = %v, want none", report.Errors)
|
|
}
|
|
if len(report.Jobs) != 1 {
|
|
t.Fatalf("report rows = %d, want 1", len(report.Jobs))
|
|
}
|
|
|
|
row := report.Jobs[0]
|
|
// An exact job keeps the af05f05c row: the source split into path segments under the
|
|
// file-system root, with the additive whole path next to it.
|
|
if row.FullPath != source || row.Base != "/" || !reflect.DeepEqual(row.Path, pathSegments(source)) {
|
|
t.Fatalf("row identity = %#v, want %q as segments below %q", row, source, "/")
|
|
}
|
|
if row.Status != JobStatusFinished || len(row.FailTargets) != 0 {
|
|
t.Fatalf("row = %#v, want a finished row without failures", row)
|
|
}
|
|
if len(row.SuccessTargets) != 1 || row.SuccessTargets[0] != target {
|
|
t.Fatalf("row success targets = %v, want %q", row.SuccessTargets, target)
|
|
}
|
|
|
|
wantHash := sha256.Sum256(content)
|
|
if row.SHA256 != hex.EncodeToString(wantHash[:]) {
|
|
t.Fatalf("row SHA256 = %q, want %x", row.SHA256, wantHash)
|
|
}
|
|
})
|
|
|
|
t.Run("refused target", func(t *testing.T) {
|
|
// An existing target without Overwrite is refused, which is an item outcome: the row
|
|
// keeps the failure and the run is not an error.
|
|
target := writeSourceFile(t, root, "existing.txt", []byte("existing"))
|
|
handler, getter := NewReportGetter()
|
|
|
|
copyer, err := New(context.Background(), AccurateJob(source, []string{target}), WithEventHandler(handler))
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
if err := copyer.WaitErr(); err != nil {
|
|
t.Fatalf("WaitErr() error = %v, want nil: a failed target is an item outcome", err)
|
|
}
|
|
|
|
report := getter()
|
|
if len(report.Errors) != 0 {
|
|
t.Fatalf("report errors = %v, want none", report.Errors)
|
|
}
|
|
if len(report.Jobs) != 1 {
|
|
t.Fatalf("report rows = %d, want 1", len(report.Jobs))
|
|
}
|
|
|
|
row := report.Jobs[0]
|
|
if row.FullPath != source || row.Status != JobStatusFinished {
|
|
t.Fatalf("row = %#v", row)
|
|
}
|
|
if len(row.FailTargets) != 1 || row.FailTargets[target] == nil {
|
|
t.Fatalf("row fail targets = %v, want %q", row.FailTargets, target)
|
|
}
|
|
if len(row.SuccessTargets) != 0 {
|
|
t.Fatalf("row success targets = %v, want none", row.SuccessTargets)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestDeliveredBatchesAreFreshSlices pins that a caller may keep what it received: every delivery
|
|
// owns its slice, so a later flush never overwrites a batch the caller still holds. Result order is
|
|
// unspecified, so the callback records the content of each batch as it arrives and the test
|
|
// compares that record with the slices it kept.
|
|
func TestDeliveredBatchesAreFreshSlices(t *testing.T) {
|
|
root := t.TempDir()
|
|
const total = 4
|
|
|
|
items := make([]Item, 0, total)
|
|
for index := 0; index < total; index++ {
|
|
name := fmt.Sprintf("%02d.txt", index)
|
|
source := writeSourceFile(t, root, name, []byte(name))
|
|
items = append(items, newFixtureItem(source, filepath.Join(root, "target", name)))
|
|
}
|
|
|
|
var (
|
|
lock sync.Mutex
|
|
kept [][]Result
|
|
observed []string
|
|
)
|
|
callback := func(results []Result) error {
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
// Keep the exact slice the callback received, plus what it held at that moment.
|
|
kept = append(kept, results)
|
|
observed = append(observed, results[0].Targets[0].Path)
|
|
return nil
|
|
}
|
|
snapshot := func() ([][]Result, []string) {
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
return append([][]Result(nil), kept...), append([]string(nil), observed...)
|
|
}
|
|
|
|
stream, err := NewStream(context.Background(), callback,
|
|
WithResultBuffer(1), WithResultBatch(1), WithResultFlushInterval(time.Hour), Overwrite(true))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := stream.Submit(items...); err != nil {
|
|
t.Fatalf("Submit() error = %v", err)
|
|
}
|
|
if err := stream.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
|
|
kept, observed = snapshot()
|
|
if len(kept) != total {
|
|
t.Fatalf("delivered %d batches, want %d", len(kept), total)
|
|
}
|
|
seen := make(map[string]bool, total)
|
|
for index, batch := range kept {
|
|
if len(batch) != 1 {
|
|
t.Fatalf("batch %d = %d results, want 1", index, len(batch))
|
|
}
|
|
if got := batch[0].Targets[0].Path; got != observed[index] {
|
|
t.Fatalf("batch %d changed after later deliveries: %q, want %q", index, got, observed[index])
|
|
}
|
|
if seen[observed[index]] {
|
|
t.Fatalf("target %q was delivered twice", observed[index])
|
|
}
|
|
seen[observed[index]] = true
|
|
}
|
|
}
|
|
|
|
// TestEventHandlerSeesExactlyOneFinishedEvent pins the event contract: every registration receives
|
|
// one EventFinished, after the count and progress events that carry the finished flag.
|
|
func TestEventHandlerSeesExactlyOneFinishedEvent(t *testing.T) {
|
|
root := t.TempDir()
|
|
item := newFixtureItem(
|
|
writeSourceFile(t, root, "source.txt", []byte("fixture")),
|
|
filepath.Join(root, "target.txt"),
|
|
)
|
|
|
|
var (
|
|
lock sync.Mutex
|
|
events []Event
|
|
)
|
|
handler := func(event Event) {
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
events = append(events, event)
|
|
}
|
|
|
|
if err := runFixture(context.Background(), newStreamFixture(item), []Item{item}, WithEventHandler(handler)); err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
finished, countFinished, progressFinished := 0, 0, 0
|
|
for _, event := range events {
|
|
switch e := event.(type) {
|
|
case *EventFinished:
|
|
finished++
|
|
case *EventUpdateCount:
|
|
if e.Finished {
|
|
countFinished++
|
|
}
|
|
case *EventUpdateProgress:
|
|
if e.Finished {
|
|
progressFinished++
|
|
}
|
|
}
|
|
}
|
|
if finished != 1 {
|
|
t.Fatalf("EventFinished delivered %d times, want exactly 1", finished)
|
|
}
|
|
if countFinished != 1 || progressFinished != 1 {
|
|
t.Fatalf("finished count/progress events = %d/%d, want 1/1", countFinished, progressFinished)
|
|
}
|
|
}
|
|
|
|
// panickingItem fails when ACP describes it, which is the caller-code boundary the engine wraps.
|
|
type panickingItem struct{}
|
|
|
|
func (*panickingItem) Source() string { panic("item source fixture") }
|
|
func (*panickingItem) Targets() []string { return nil }
|
|
|
|
// TestItemPanicBecomesThatItemsResultError pins the outlet of a panic in caller-owned item code:
|
|
// the item is reported exactly once with the panic as its error, so the run continues and Wait
|
|
// reports no run-level failure.
|
|
func TestItemPanicBecomesThatItemsResultError(t *testing.T) {
|
|
callback, batches := collectResults()
|
|
stream, err := NewStream(context.Background(), callback, WithResultFlushInterval(time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
item := &panickingItem{}
|
|
if err := stream.Submit(item); err != nil {
|
|
t.Fatalf("Submit() error = %v, want the item accepted", err)
|
|
}
|
|
if err := stream.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
if err := stream.Wait(); err != nil {
|
|
t.Fatalf("Wait() error = %v, want nil: a panicking item is an item outcome", err)
|
|
}
|
|
|
|
got := batches()
|
|
if len(got) != 1 || len(got[0]) != 1 {
|
|
t.Fatalf("delivered %#v, want one batch of one result", got)
|
|
}
|
|
if got[0][0].Job != Item(item) {
|
|
t.Fatalf("result job = %#v, want the submitted item", got[0][0].Job)
|
|
}
|
|
if got[0][0].Err == nil || !strings.Contains(got[0][0].Err.Error(), "Item.Source panicked") {
|
|
t.Fatalf("result error = %v, want the wrapped item panic", got[0][0].Err)
|
|
}
|
|
}
|
|
|
|
// TestSubmitRacingCloseKeepsTheFeedCountersSafe pins the concurrency contract of the feed: a
|
|
// caller that closes the run while another goroutine is still submitting must not race the
|
|
// pipeline's final count event, and a submission the closed run refuses is that caller's error.
|
|
func TestSubmitRacingCloseKeepsTheFeedCountersSafe(t *testing.T) {
|
|
root := t.TempDir()
|
|
const total = 200
|
|
|
|
items := make([]Item, 0, total)
|
|
for index := 0; index < total; index++ {
|
|
name := fmt.Sprintf("%04d.txt", index)
|
|
source := writeSourceFile(t, root, name, []byte(name))
|
|
items = append(items, newFixtureItem(source, filepath.Join(root, "target", name)))
|
|
}
|
|
|
|
var (
|
|
lock sync.Mutex
|
|
results []Result
|
|
)
|
|
stream, err := NewStream(context.Background(), func(batch []Result) error {
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
results = append(results, batch...)
|
|
return nil
|
|
}, WithReadBuffer(1), WithResultBatch(1))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The feeder loops until the run refuses it, so Close lands while it is inside Submit.
|
|
refused := make(chan error, 1)
|
|
go func() {
|
|
for _, item := range items {
|
|
if err := stream.Submit(item); err != nil {
|
|
refused <- err
|
|
return
|
|
}
|
|
time.Sleep(time.Microsecond)
|
|
}
|
|
refused <- nil
|
|
}()
|
|
|
|
time.Sleep(5 * time.Millisecond)
|
|
if err := stream.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
waitErr := stream.Wait()
|
|
|
|
// A refusal is a submission error and therefore the run's error; a feeder that finished
|
|
// before Close leaves the run successful.
|
|
if err := <-refused; err == nil {
|
|
if waitErr != nil {
|
|
t.Fatalf("Wait() error = %v, want nil after a complete feed", waitErr)
|
|
}
|
|
} else {
|
|
if !errors.Is(err, errStreamClosed) {
|
|
t.Fatalf("Submit() error = %v, want the closed refusal", err)
|
|
}
|
|
if !errors.Is(waitErr, errStreamClosed) {
|
|
t.Fatalf("Wait() error = %v, want the submission refusal", waitErr)
|
|
}
|
|
}
|
|
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
seen := make(map[Item]int, len(results))
|
|
for _, result := range results {
|
|
seen[result.Job]++
|
|
}
|
|
if len(results) == 0 {
|
|
t.Fatal("the run delivered no result at all")
|
|
}
|
|
for item, count := range seen {
|
|
if count != 1 {
|
|
t.Fatalf("item %#v was delivered %d times, want exactly 1", item, count)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCloseWaitsForTheSubmissionCountEvent(t *testing.T) {
|
|
// Hold the event handoff after the item has entered the read buffer, just as a blocked
|
|
// event handler can. Close must not let the pipeline close that handoff under its sender.
|
|
input := writeSourceFile(t, t.TempDir(), "source.txt", []byte("fixture"))
|
|
stream := newTestStream(t, WithReadBuffer(1))
|
|
stream.eventCh = make(chan Event)
|
|
submitted := make(chan error, 1)
|
|
go func() { submitted <- stream.Submit(&SimpleJob{Path: input}) }()
|
|
<-stream.readCh
|
|
select {
|
|
case err := <-submitted:
|
|
t.Fatalf("Submit() returned %v before its count event was received", err)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
// Shutdown must wait until the feed relinquishes its count-event send.
|
|
closed := make(chan error, 1)
|
|
go func() { closed <- stream.Close() }()
|
|
returnedEarly := false
|
|
select {
|
|
case <-closed:
|
|
returnedEarly = true
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
<-stream.eventCh
|
|
if err := <-submitted; err != nil {
|
|
t.Fatalf("Submit() error = %v", err)
|
|
}
|
|
if !returnedEarly {
|
|
if err := <-closed; err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
}
|
|
if returnedEarly {
|
|
t.Fatal("Close returned while the feed still owned a blocked count-event send")
|
|
}
|
|
}
|
|
|
|
func TestSubmitCountsOnlyAcceptedItems(t *testing.T) {
|
|
// The first item fills the read buffer; a fatal stop then refuses the next handoff.
|
|
input := writeSourceFile(t, t.TempDir(), "source.txt", []byte("fixture"))
|
|
stream := newTestStream(t, WithReadBuffer(1))
|
|
if err := stream.Submit(&SimpleJob{Path: input}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stream.stopHard()
|
|
if err := stream.Submit(&SimpleJob{Path: input}); !errors.Is(err, errStreamStopped) {
|
|
t.Fatalf("Submit() error = %v, want the fatal-stop refusal", err)
|
|
}
|
|
|
|
// Indexed totals describe accepted work, including when the feed loses a handoff.
|
|
if files, size := stream.countFiles.Load(), stream.countBytes.Load(); files != 1 || size != int64(len("fixture")) {
|
|
t.Fatalf("indexed files/bytes = %d/%d, want 1/%d", files, size, len("fixture"))
|
|
}
|
|
if err := stream.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestItemTargetsPanicBecomesThatItemsResultError pins the second half of the item-panic outlet:
|
|
// a panic in Item.Targets is that item's error, exactly like a panic in Item.Source.
|
|
func TestItemTargetsPanicBecomesThatItemsResultError(t *testing.T) {
|
|
callback, batches := collectResults()
|
|
stream, err := NewStream(context.Background(), callback, WithResultFlushInterval(time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
item := &panickingTargetsItem{}
|
|
if err := stream.Submit(item); err != nil {
|
|
t.Fatalf("Submit() error = %v, want the item accepted", err)
|
|
}
|
|
if err := stream.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
if err := stream.Wait(); err != nil {
|
|
t.Fatalf("Wait() error = %v, want nil: a panicking item is an item outcome", err)
|
|
}
|
|
|
|
got := batches()
|
|
if len(got) != 1 || len(got[0]) != 1 {
|
|
t.Fatalf("delivered %#v, want one batch of one result", got)
|
|
}
|
|
if got[0][0].Err == nil || !strings.Contains(got[0][0].Err.Error(), "Item.Targets panicked") {
|
|
t.Fatalf("result error = %v, want the wrapped targets panic", got[0][0].Err)
|
|
}
|
|
}
|
|
|
|
// panickingTargetsItem describes itself successfully and fails when its targets are read.
|
|
type panickingTargetsItem struct{ source string }
|
|
|
|
func (i *panickingTargetsItem) Source() string { return i.source }
|
|
func (*panickingTargetsItem) Targets() []string { panic("item targets fixture") }
|
|
|
|
// TestSuccessIsDeliveredByTheFlushInterval pins the timer branch of the delivery stage: a success
|
|
// below the batch size still reaches the caller once the interval elapses, without Close.
|
|
func TestSuccessIsDeliveredByTheFlushInterval(t *testing.T) {
|
|
root := t.TempDir()
|
|
item := newFixtureItem(
|
|
writeSourceFile(t, root, "source.txt", []byte("fixture")),
|
|
filepath.Join(root, "target.txt"),
|
|
)
|
|
|
|
delivered := make(chan []Result, 1)
|
|
stream, err := NewStream(context.Background(), func(results []Result) error {
|
|
delivered <- append([]Result(nil), results...)
|
|
return nil
|
|
}, WithResultBuffer(4), WithResultBatch(4), WithResultFlushInterval(100*time.Millisecond), Overwrite(true))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := stream.Submit(item); err != nil {
|
|
t.Fatalf("Submit() error = %v", err)
|
|
}
|
|
|
|
select {
|
|
case results := <-delivered:
|
|
if len(results) != 1 || results[0].Job != Item(item) || results[0].Err != nil {
|
|
t.Fatalf("delivered %#v, want the successful item", results)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the flush interval never delivered the successful result")
|
|
}
|
|
if err := stream.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPublishResultEscapesAHardStop pins the result handoff's fatal-failure escape: a full result
|
|
// buffer cannot leave the reporting stage blocked after the pipeline hard-stopped.
|
|
func TestPublishResultEscapesAHardStop(t *testing.T) {
|
|
copyer := newTestStream(t)
|
|
// A full result buffer with no delivery stage: only the hard stop can release this send.
|
|
copyer.resultCh = make(chan Result, 1)
|
|
copyer.resultCh <- Result{}
|
|
copyer.stopHard()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
copyer.publishResult(Result{})
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("publishResult stayed blocked on the result handoff after a hard stop")
|
|
}
|
|
}
|