Files
acp/transfer.go
Samuel Cui b5f28fa474 Release ACP v0.2.7
Add ordered content sources and targets to the shared copy pipeline. Observe
fs.File metadata before target creation, bind fs.FS sources, and expose
fatal-stop notification while preserving native copy behavior.
2026-10-15 18:18:39 +08:00

251 lines
6.7 KiB
Go

package acp
import (
"context"
"errors"
"fmt"
"hash"
"sync"
"sync/atomic"
mapset "github.com/deckarep/golang-set/v2"
)
// transfer owns a file's buffers, concurrent consumers and terminal outcomes.
type transfer struct {
copyer *StreamCopyer
job *writeJob
ahead *readAhead
start int64
chunks chan *chunkBuffer
hashes chan *chunkBuffer
readDone, hashDone, sourceDone, sourceCacheDone, writeDone chan struct{}
sourceReady chan struct{}
sourceReadyOnce sync.Once
readTurn, writeTurn <-chan struct{}
stop chan struct{}
stopOnce, hashOnce sync.Once
readErr, sourceErr, hashErr error
digest []byte
noContent bool
admitted bool
publications []publicationTurn
targets sync.WaitGroup
}
func newTransfer(c *StreamCopyer, job *writeJob, ahead *readAhead, start int64) *transfer {
// Channels hold references; backing capacity is shared across every admitted transfer.
size := int64(0)
if job.stat != nil {
size = max(job.stat.size, 0)
}
capacity := max(1, int((min(size, readAheadBytes)+batchSize-1)/batchSize))
t := &transfer{
copyer: c, job: job, ahead: ahead, start: start,
chunks: make(chan *chunkBuffer, capacity),
readDone: make(chan struct{}), hashDone: make(chan struct{}), sourceDone: make(chan struct{}),
sourceCacheDone: make(chan struct{}), writeDone: make(chan struct{}), stop: make(chan struct{}),
noContent: job.itemError != nil || job.skipContent || job.cacheHit || (job.reader == nil && job.contentSource == nil),
}
if job.contentSource != nil {
t.sourceReady = make(chan struct{})
}
if !t.noContent && c.hashPolicy.producesHash() {
t.hashes = make(chan *chunkBuffer, 4)
}
return t
}
func (t *transfer) abort() { t.stopOnce.Do(func() { close(t.stop) }) }
func (t *transfer) panicFailure(stage string, value any) error {
err := panicError(stage, value)
t.copyer.setError(err)
t.copyer.stopHard()
return err
}
func (t *transfer) wait(turn <-chan struct{}) bool {
if turn == nil {
return true
}
select {
case <-turn:
return true
case <-t.copyer.hardStop:
return false
}
}
func (t *transfer) hashContent() {
// Digest completion cannot strand a target after a hash panic.
defer close(t.hashDone)
if t.hashes == nil {
return
}
defer func() {
if value := recover(); value != nil {
t.hashErr = t.panicFailure("hash consumer", value)
}
for chunk := range t.hashes {
chunk.release()
}
}()
sha := sha256Pool.Get().(hash.Hash)
defer sha256Pool.Put(sha)
sha.Reset()
for chunk := range t.hashes {
func() { defer chunk.release(); _, _ = sha.Write(chunk.data) }()
}
if t.readErr == nil {
t.digest = sha.Sum(nil)
}
}
func (t *transfer) publishHash() {
// Only logically admitted work publishes a computed digest into public item facts.
<-t.hashDone
<-t.readDone
t.hashOnce.Do(func() {
if t.noContent {
return
}
t.job.setHash(t.digest)
if t.readErr == nil && t.hashErr == nil {
t.job.validateHash()
}
})
}
func (t *transfer) cacheSource() {
// Source path identity remains stable until rewrite publication observes this completion.
defer close(t.sourceCacheDone)
defer func() {
if value := recover(); value != nil {
t.panicFailure("source cache", value)
}
}()
t.publishHash()
<-t.sourceDone
if t.job.sourcePath != "" {
t.copyer.refreshCachePath(t.job.sourcePath, t.job.baseJob, false)
}
}
func (t *transfer) run(ctx context.Context, out chan<- *baseJob, writing chan struct{}, cntr *counter, exhausted mapset.Set[string]) {
// Reading and hashing can run ahead while target data remains ordered.
c := t.copyer
unstarted := t.job.endpoints && c.markUnstarted(ctx, t.job.baseJob)
if unstarted {
t.noContent = true
}
drained := context.WithoutCancel(ctx)
go c.wrap(drained, t.hashContent)
go c.wrap(drained, t.readContent)
active, turnReleased := false, false
defer func() {
if value := recover(); value != nil {
t.job.itemError = t.panicFailure("transfer", value)
}
t.abort()
if !turnReleased {
t.finishWrite()
}
for chunk := range t.chunks {
chunk.release()
}
<-t.readDone
<-t.hashDone
<-t.sourceDone
if active {
t.cacheSource()
}
// Failed or unstarted outputs release in reservation order after rewrite's cache dependency.
for _, turn := range t.publications {
if turn.release != nil {
turn.release()
}
}
t.targets.Wait()
t.job.itemError = errors.Join(t.job.itemError, t.sourceErr)
c.publish(out, t.job.baseJob)
}()
// The public graceful-stop boundary remains logical Copy admission, not speculative Read.
if !t.wait(t.writeTurn) {
return
}
if !t.admitted {
select {
case writing <- struct{}{}:
case <-c.hardStop:
return
}
}
held := true
defer func() {
if held {
<-writing
}
}()
if unstarted || (!t.job.endpoints && c.markUnstarted(ctx, t.job.baseJob)) {
return
}
t.ahead.advance(t.job.order, t.start)
t.job.startWrite()
active = true
atomic.AddInt64(&cntr.files, 1)
// Targetless and cache-hit work still consumes bounded read progress without a target writer.
if t.noContent {
if t.job.cacheHit && t.job.stat != nil {
atomic.AddInt64(&cntr.bytes, t.job.stat.size)
}
} else {
t.writeContent(cntr, exhausted)
}
<-writing
held = false
t.finishWrite()
turnReleased = true
}
func (t *transfer) finishWrite() {
// The next data producer need not wait for hash, Close, metadata or results.
end := t.start
if t.job.stat != nil {
end += max(t.job.stat.size, 0)
}
t.ahead.advance(t.job.order+1, end)
close(t.writeDone)
}
func (t *transfer) stopped() bool {
if t.job.endpoints && t.copyer.ctx.Err() != nil {
return true
}
select {
case <-t.stop:
return true
case <-t.copyer.hardStop:
return true
default:
return false
}
}
func (t *transfer) stoppedError() error {
if t.job.endpoints && t.copyer.ctx.Err() != nil {
return t.copyer.ctx.Err()
}
return fmt.Errorf("source prefetch stopped")
}
func (t *transfer) stopEndpoint(err error) {
if err != nil {
t.copyer.setCallbackStop(err)
t.copyer.setError(err)
}
}