Files
acp/copy.go
T
Samuel Cui c3bcd856aa feat!: stream caller-owned items and reduce the content options
Replaces the streaming request/result API with caller-owned items and
consolidates the content options, following the Job execution performance
design.

- `Item`, `BatchSource` and `Run` replace `StreamRequest`, `StreamResult`,
  `StreamSource`, `StreamSink` and `RunStream`. Every accepted item receives
  exactly one terminal callback from a single goroutine and without doing
  I/O; an item ACP cannot process reports `Failed`, and a completion carries
  one outcome per requested target in request order with its error identity
  preserved. A graceful stop stops feeding, finishes the items in flight and
  reports the rest, and a hard stop abandons per-item accounting.
- One `HashPolicy` replaces `WithHash`, `WithSignatureCache` and
  `ForceRehash`. A stored hash is read whenever the policy uses the cache,
  reused only where the policy allows it, and refreshed only when it differs.
- A source-only `WithReadMode` selects buffered (the default) or mapped
  reads, and buffered sources open with `O_NOATIME` where the platform has
  it. `LinearDevice` keeps request ordering, append targets and abort-on-error
  but no longer selects the read mode.
- `Overwrite` becomes a target-device option, `WithReadBuffer` sets the read
  buffer, and `SelectFiles` returns the entries the command line copies,
  replacing `AccurateJob`, `WildcardJob` and the walk options.
- Copy consumers share one reference-counted chunk buffer each from a common
  pool, and the per-item write goroutines finish before the result channel
  closes, so no finished item can be dropped.

The command line tools, the documentation and the test suite move with the
API, including semantic tests for the callback contract, the stop and drain
paths, the hash policy matrix and the read modes.
2026-09-19 02:03:34 +08:00

382 lines
9.7 KiB
Go

package acp
import (
"context"
"errors"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
mapset "github.com/deckarep/golang-set/v2"
sha256 "github.com/minio/sha256-simd"
"github.com/samber/lo"
)
const (
batchSize = 1 * 1024 * 1024
)
var (
sha256Pool = &sync.Pool{New: func() interface{} { return sha256.New() }}
)
// chunkBuffer is one read buffer shared by the hash consumer and every target writer of
// one item. Each consumer holds a reference and the last release returns the buffer to
// the shared pool, so allocation churn disappears while the read-ahead window stays
// exactly what the consumer channels allow.
type chunkBuffer struct {
data []byte
refs int32
}
var chunkPool = sync.Pool{
New: func() interface{} {
return &chunkBuffer{data: make([]byte, batchSize)}
},
}
// acquireChunk returns a pooled buffer holding one producer reference.
func acquireChunk() *chunkBuffer {
chunk := chunkPool.Get().(*chunkBuffer)
chunk.data = chunk.data[:cap(chunk.data)]
chunk.refs = 1
return chunk
}
// retain adds one consumer reference to the buffer.
func (c *chunkBuffer) retain() *chunkBuffer {
atomic.AddInt32(&c.refs, 1)
return c
}
// release drops one reference and returns the buffer once no consumer holds it.
func (c *chunkBuffer) release() {
if atomic.AddInt32(&c.refs, -1) == 0 {
chunkPool.Put(c)
}
}
func (c *Copyer) copy(ctx context.Context, prepared <-chan *writeJob) <-chan *baseJob {
ch := make(chan *baseJob, 128)
var copying sync.WaitGroup
done := make(chan struct{})
var reporting sync.WaitGroup
defer func() {
go wrap(ctx, func() {
copying.Wait()
close(done)
reporting.Wait()
close(ch)
})
}()
cntr := new(counter)
reporting.Add(1)
go wrap(ctx, func() {
defer reporting.Done()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.submit(&EventUpdateProgress{Bytes: atomic.LoadInt64(&cntr.bytes), Files: atomic.LoadInt64(&cntr.files)})
case <-done:
c.submit(&EventUpdateProgress{Bytes: atomic.LoadInt64(&cntr.bytes), Files: atomic.LoadInt64(&cntr.files), Finished: true})
return
}
}
})
noSpaceDevices := mapset.NewSet[string]()
for idx := 0; idx < c.toDevice.threads; idx++ {
copying.Add(1)
go wrap(ctx, func() {
defer copying.Done()
// Consume prepared readers until the stage closes, so a stopped pipeline
// still reports the items it holds instead of dropping them. One worker
// writes one item at a time: that bounds target concurrency by the device
// thread count and keeps a linear target in request order.
for job := range prepared {
if c.stopped(ctx) {
job.finishSource()
if !c.abandon(job.baseJob, c.abandonment(ctx)) {
return
}
continue
}
c.write(ctx, job, ch, cntr, noSpaceDevices)
}
})
}
return ch
}
func (c *Copyer) write(ctx context.Context, job *writeJob, ch chan<- *baseJob, cntr *counter, noSpaceDevices mapset.Set[string]) {
// Release the source and publish ownership only after every consumer stops. A
// graceful stop still reports the item this stage finished.
var wg sync.WaitGroup
defer func() {
wg.Wait()
job.finishSource()
job.setStatus(jobStatusFinishing)
select {
case ch <- job.baseJob:
case <-c.hardStop:
}
}()
// Reject source changes before creating any targets. Every requested target is
// reported failed, so the item stays a completion with failed outcomes.
job.setStatus(jobStatusCopying)
if job.skipContent {
atomic.AddInt64(&cntr.files, 1)
return
}
if job.size != job.stat.size {
job.failAll(fmt.Errorf("source size changed, indexed=%d current=%d", job.stat.size, job.size))
return
}
if job.cacheHit {
atomic.AddInt64(&cntr.files, 1)
atomic.AddInt64(&cntr.bytes, job.size)
return
}
// Report every target failed when every requested target device is exhausted.
targetDevices := lo.Map(job.targets, func(target string, _ int) string { return c.getDevice(target) })
if len(targetDevices) > 0 && noSpaceDevices.Contains(targetDevices...) {
job.failAll(ErrTargetNoSpace)
return
}
// Track progress and close every consumer after the source reader finishes.
atomic.AddInt64(&cntr.files, 1)
chans := make([]chan *chunkBuffer, 0, len(job.targets)+1)
defer func() {
for _, ch := range chans {
close(ch)
}
}()
// Open each viable target before reading the source once.
var readErr error
for _, target := range job.targets {
target := target
// Reject exhausted targets before reserving capacity.
dev := c.getDevice(target)
if noSpaceDevices.Contains(dev) {
job.fail(target, ErrTargetNoSpace)
continue
}
if err := c.getDiskUsageCache(dev).check(job.size); err != nil {
if errors.Is(err, ErrTargetNoSpace) {
noSpaceDevices.Add(dev)
}
c.endLinearTarget(err)
job.fail(target, fmt.Errorf("check disk usage have error, %w", err))
continue
}
// Prepare the target file before attaching its stream consumer.
if err := mappingError(os.MkdirAll(filepath.Dir(target), os.ModePerm)); err != nil {
if checkErrorAbort(err) {
noSpaceDevices.Add(dev)
}
c.endLinearTarget(err)
job.fail(target, fmt.Errorf("mkdir dst dir fail, %w", err))
continue
}
// Invalidate an existing cache before O_TRUNC can replace its content.
if c.createFlag&os.O_TRUNC != 0 {
c.invalidateSignaturePath(target)
}
file, err := os.OpenFile(target, c.createFlag, job.stat.mode)
if err = mappingError(err); err != nil {
if checkErrorAbort(err) {
noSpaceDevices.Add(dev)
}
c.endLinearTarget(err)
job.fail(target, fmt.Errorf("open dst file fail, %w", err))
continue
}
c.invalidateSignature(file, target)
if !job.copyer.toDevice.linear && job.size > 0 {
if err := truncate(file, job.size); err != nil {
_ = file.Close()
_ = os.Remove(target)
job.fail(target, fmt.Errorf("truncate dst file fail, %w", err))
continue
}
}
// Consume source buffers in one managed writer for this target.
ch := make(chan *chunkBuffer, 4)
chans = append(chans, ch)
wg.Add(1)
go wrap(ctx, func() {
defer wg.Done()
// Settle target status and discard any incomplete file before exiting.
var rerr error
defer func() {
if rerr == nil {
job.success(target)
return
}
rerr = mappingError(rerr)
if checkErrorAbort(rerr) {
noSpaceDevices.Add(dev)
}
c.endLinearTarget(rerr)
// avoid block channel
for chunk := range ch {
chunk.release()
}
job.fail(target, fmt.Errorf("write dst file fail, %w", rerr))
if err := os.Remove(target); err != nil {
c.reportError(job.path, target, fmt.Errorf("delete failed file has error, %w", err))
}
}()
// Write every source buffer before publishing the durability boundary.
defer func() {
if file != nil {
_ = file.Close()
}
}()
for chunk := range ch {
size := len(chunk.data)
n, err := file.Write(chunk.data)
chunk.release()
if err != nil {
rerr = fmt.Errorf("write fail, %w", err)
return
}
if size != n {
rerr = fmt.Errorf("write fail, unexpected writen bytes return, read= %d write= %d", size, n)
return
}
}
// A linear target publishes its durability boundary when the caller unmounts it.
if !c.toDevice.linear {
if err := file.Sync(); err != nil {
rerr = fmt.Errorf("sync dst file fail, %w", err)
return
}
}
if err := file.Close(); err != nil {
file = nil
rerr = fmt.Errorf("close dst file fail, %w", err)
return
}
file = nil
if readErr != nil {
rerr = readErr
return
}
})
}
targetWriters := len(chans)
// Add hashing as another consumer of the shared source stream.
if c.hashPolicy.producesHash() {
sha := sha256Pool.Get().(hash.Hash)
sha.Reset()
ch := make(chan *chunkBuffer, 4)
chans = append(chans, ch)
wg.Add(1)
go wrap(ctx, func() {
defer wg.Done()
defer sha256Pool.Put(sha)
for chunk := range ch {
sha.Write(chunk.data)
chunk.release()
}
// A stopped read describes only part of the source, so it has no content hash.
if readErr != nil {
job.setHash(nil)
return
}
job.setHash(sha.Sum(nil))
})
}
// Read the source only when at least one target or hash consumer needs it.
if len(chans) == 0 {
return
}
var copied int64
copied, readErr = c.streamCopy(chans, job.reader, &cntr.bytes)
if readErr == nil && copied != job.size {
readErr = fmt.Errorf("source size changed while copying, expected=%d copied=%d", job.size, copied)
}
if readErr == nil && c.hashPolicy.producesHash() {
job.validateHash()
}
if readErr != nil && targetWriters == 0 {
// An item with no target outcome to report against could not be processed at all.
c.reportItemError(job.path, "", readErr)
job.itemError = readErr
}
}
// streamCopy reads the source once and hands every chunk to each consumer. A graceful
// stop finishes the item in flight; only a pipeline failure ends the read early.
func (c *Copyer) streamCopy(dsts []chan *chunkBuffer, src io.ReadCloser, bytes *int64) (int64, error) {
var copied int64
for {
chunk := acquireChunk()
n, err := io.ReadFull(src, chunk.data)
if err != nil {
if !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
chunk.release()
return copied, fmt.Errorf("slice mmap fail, %w", err)
}
}
// Every consumer releases its own reference; the producer releases the last one
// after the size is captured, because the buffer may be reused immediately.
chunk.data = chunk.data[:n]
for _, ch := range dsts {
ch <- chunk.retain()
}
copied += int64(n)
atomic.AddInt64(bytes, int64(n))
chunk.release()
if n < batchSize {
return copied, nil
}
select {
case <-c.hardStop:
return copied, fmt.Errorf("copy stopped by pipeline failure")
default:
}
}
}