Files
acp/stream_test.go
T
Samuel Cui 4665ef20f9 Prepare ACP 0.2.2
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.
2026-10-02 17:14:11 +08:00

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")
}
}