mirror of
https://github.com/samuelncui/acp.git
synced 2026-11-08 04:21:16 +00:00
Simplify indexed copy facts and unify staged output completion. Fix cleanup, rewrite persistence, native paths, and terminal reporting. Maintain compatibility, upstream notices, and automated release checks.
305 lines
8.7 KiB
Go
305 lines
8.7 KiB
Go
package acp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sync"
|
|
|
|
"github.com/samuelncui/acp/mmap"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
type prepareResult struct {
|
|
order uint64
|
|
job *writeJob
|
|
release chan struct{}
|
|
}
|
|
|
|
// itemSource is the one descriptor an item owns for its whole lifecycle, together with the reader
|
|
// that delivers its content. The reader is what closes the descriptor: an ordinary file is the
|
|
// reader itself, and a mapping is released before its descriptor closes.
|
|
type itemSource struct {
|
|
file *os.File
|
|
reader io.ReadCloser
|
|
}
|
|
|
|
// openSourceContent opens the single descriptor an item owns, together with the reader that
|
|
// delivers its content. It is a variable so a test can observe which descriptor an item's cache
|
|
// read and cache write use.
|
|
var openSourceContent = func(path string, mode ReadMode, info os.FileInfo) (itemSource, error) {
|
|
// Only a caller that asks for it reads through a mapping, which retains its descriptor.
|
|
if mode == ReadMapped {
|
|
readerAt, err := mmap.OpenWithInfo(path, info)
|
|
if err != nil {
|
|
return itemSource{}, fmt.Errorf("open src file by mmap fail, %w", err)
|
|
}
|
|
|
|
return itemSource{
|
|
file: readerAt.File(),
|
|
reader: mmap.NewReader(readerAt),
|
|
}, nil
|
|
}
|
|
|
|
// Every other source is read buffered, without updating its access time.
|
|
file, err := openSource(path)
|
|
if err != nil {
|
|
return itemSource{}, fmt.Errorf("open src file fail, %w", err)
|
|
}
|
|
return itemSource{file: file, reader: file}, nil
|
|
}
|
|
|
|
// prepareItem opens the one descriptor an item owns and reads its stored hash through it. That
|
|
// descriptor serves the whole item: the stored hash is read through it, the content is read
|
|
// through it, and the computed hash is published through it before it closes.
|
|
func (c *StreamCopyer) prepareItem(job *baseJob) *writeJob {
|
|
// An item needs its source when it writes somewhere or hashes content, and a targetless item
|
|
// reads its stored hash only where the policy reuses one.
|
|
needsContent := len(job.targets) > 0 || c.hashPolicy.readsContent()
|
|
reuses := c.signatures != nil && len(job.targets) == 0 && c.hashPolicy.reusesCache()
|
|
|
|
// A policy that neither reads content nor uses the cache completes without a source.
|
|
if !needsContent && !reuses {
|
|
return c.noContentJob(job)
|
|
}
|
|
|
|
// Only an item that reads content opens a mapping: a reuse-only item reads a stored hash.
|
|
mode := job.readMode
|
|
if !needsContent {
|
|
mode = ReadBuffered
|
|
}
|
|
source, err := openSourceContent(job.path, mode, job.stat.info)
|
|
if err != nil {
|
|
// A reuse-only item owns its descriptor for the stored hash alone, so a source it cannot
|
|
// open is a cache miss with a warning instead of an item failure.
|
|
if reuses {
|
|
c.signatures.recordFailure(job.path, err)
|
|
c.signatures.incrementMiss()
|
|
if !needsContent {
|
|
return c.noContentJob(job)
|
|
}
|
|
}
|
|
|
|
// A source that cannot be opened is an item outcome. The marked job travels to the
|
|
// reporting stage instead of a side channel.
|
|
c.logf(logrus.ErrorLevel, "prepare source failed, source= %q, %v", job.path, err)
|
|
job.itemError = err
|
|
return newWriteJob(job, nil, false)
|
|
}
|
|
|
|
// Retain ownership until the prepared job takes it, including a cache lookup panic.
|
|
defer func() {
|
|
if source.reader != nil {
|
|
_ = source.reader.Close()
|
|
}
|
|
}()
|
|
|
|
// Read the source entry once. Reuse accounts for misses and warnings; refresh-only reads
|
|
// silently replace unusable entries, preserving that policy's existing diagnostics.
|
|
reused := false
|
|
if reuses {
|
|
if hash, ok := c.signatures.lookup(source.file, job.path, job.stat); ok {
|
|
job.setCachedHash(hash)
|
|
reused = true
|
|
}
|
|
} else if c.signatures != nil && c.hashPolicy.refreshesCache() {
|
|
if signature, status, err := readCachedSignatureInfo(source.file, job.stat.info); err == nil && status == signatureReadHit {
|
|
job.cachedSignature = &signature
|
|
}
|
|
}
|
|
|
|
// Transfer the descriptor and indexed facts to the content stage.
|
|
wj := newWriteJob(job, source.reader, c.fromDevice.linear)
|
|
wj.source = source.file
|
|
source.reader = nil
|
|
if !needsContent && !reused {
|
|
// An item that reused no stored hash has no hash at all and is not a cache hit. Its
|
|
// descriptor still closes with the item, which owns it for its whole lifecycle.
|
|
job.setHash(nil)
|
|
wj.skipContent = true
|
|
}
|
|
return wj
|
|
}
|
|
|
|
// noContentJob settles an item whose policy needs no source content: the copy stage reports it
|
|
// without reading anything, and it is neither a cache hit nor a hash this run computed.
|
|
func (c *StreamCopyer) noContentJob(job *baseJob) *writeJob {
|
|
job.setHash(nil)
|
|
wj := newWriteJob(job, nil, c.fromDevice.linear)
|
|
wj.skipContent = true
|
|
return wj
|
|
}
|
|
|
|
// prepare opens the sources of indexed jobs. It never acts on a stop itself: a job the stop
|
|
// reached before its source opened is marked and forwarded, so the reporting stage fails it
|
|
// instead of dropping it.
|
|
func (c *StreamCopyer) prepare(ctx context.Context, indexed <-chan *baseJob) <-chan *writeJob {
|
|
// Everything this stage waits on runs on a context that never cancels, so a stop can
|
|
// never release a reader before its consumer owns it.
|
|
drained := context.WithoutCancel(ctx)
|
|
|
|
// Disable read-ahead when a linear source must wait for each consumer.
|
|
chanLen := 32
|
|
if c.fromDevice.linear {
|
|
chanLen = 0
|
|
}
|
|
|
|
// Collect every preparation outcome so skipped jobs can still advance linear ordering.
|
|
completed := make(chan prepareResult, chanLen)
|
|
prepared := make(chan *writeJob, chanLen)
|
|
var workers sync.WaitGroup
|
|
|
|
// Prepare source readers with the configured source-device concurrency.
|
|
for idx := 0; idx < c.fromDevice.threads; idx++ {
|
|
workers.Add(1)
|
|
go c.wrap(drained, func() {
|
|
defer workers.Done()
|
|
|
|
// Consume indexed jobs until source exhaustion. A stopped pipeline drains its
|
|
// input, so no accepted item is dropped.
|
|
for job := range indexed {
|
|
// An item that was already rejected while indexing needs no source.
|
|
if c.markUnstarted(ctx, job) {
|
|
if !c.sendPrepareResult(completed, prepareResult{order: job.order, job: newWriteJob(job, nil, false)}) {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
|
|
result := prepareResult{order: job.order}
|
|
if c.toDevice.linear {
|
|
result.release = make(chan struct{})
|
|
}
|
|
|
|
// Transfer the prepared item to the ordering stage before waiting on a linear
|
|
// source.
|
|
result.job = c.prepareItem(job)
|
|
if !c.sendPrepareResult(completed, result) {
|
|
return
|
|
}
|
|
if !result.job.waitConsumed() {
|
|
return
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Close preparation outcomes only after every source worker relinquishes ownership.
|
|
go c.wrap(drained, func() {
|
|
workers.Wait()
|
|
close(completed)
|
|
})
|
|
|
|
// Preserve request order only where one linear writer requires it.
|
|
go c.wrap(drained, func() {
|
|
defer close(prepared)
|
|
c.forwardPrepared(completed, prepared)
|
|
})
|
|
return prepared
|
|
}
|
|
|
|
func (c *StreamCopyer) sendPrepareResult(completed chan<- prepareResult, result prepareResult) bool {
|
|
select {
|
|
case completed <- result:
|
|
case <-c.hardStop:
|
|
result.finish()
|
|
return false
|
|
}
|
|
if result.release == nil {
|
|
return true
|
|
}
|
|
select {
|
|
case <-result.release:
|
|
return true
|
|
case <-c.hardStop:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (result prepareResult) releaseWorker() {
|
|
if result.release != nil {
|
|
close(result.release)
|
|
}
|
|
}
|
|
|
|
func (result prepareResult) finish() {
|
|
if result.job != nil {
|
|
result.job.finishSource()
|
|
}
|
|
result.releaseWorker()
|
|
}
|
|
|
|
// forwardPrepared hands prepared readers to the copy stage. A graceful stop keeps
|
|
// forwarding, so the copy stage reports every item this stage holds.
|
|
func (c *StreamCopyer) forwardPrepared(
|
|
completed <-chan prepareResult,
|
|
prepared chan<- *writeJob,
|
|
) {
|
|
// Close readers that remain owned by this stage when a hard stop ends forwarding.
|
|
defer func() {
|
|
for result := range completed {
|
|
result.finish()
|
|
}
|
|
}()
|
|
|
|
// Random targets retain completion-order concurrency.
|
|
if !c.toDevice.linear {
|
|
for result := range completed {
|
|
if result.job == nil {
|
|
continue
|
|
}
|
|
select {
|
|
case prepared <- result.job:
|
|
case <-c.hardStop:
|
|
result.finish()
|
|
return
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// A bounded reorder buffer feeds the linear writer in original request order.
|
|
next := uint64(0)
|
|
pending := make(map[uint64]prepareResult, c.fromDevice.threads)
|
|
defer func() {
|
|
for _, result := range pending {
|
|
result.finish()
|
|
}
|
|
}()
|
|
for {
|
|
select {
|
|
case <-c.hardStop:
|
|
return
|
|
case result, ok := <-completed:
|
|
if !ok {
|
|
if len(pending) != 0 {
|
|
c.setError(fmt.Errorf("ordered preparation ended before request %d", next))
|
|
}
|
|
return
|
|
}
|
|
pending[result.order] = result
|
|
}
|
|
|
|
for {
|
|
result, ok := pending[next]
|
|
if !ok {
|
|
break
|
|
}
|
|
delete(pending, next)
|
|
next++
|
|
if result.job == nil {
|
|
result.releaseWorker()
|
|
continue
|
|
}
|
|
select {
|
|
case prepared <- result.job:
|
|
result.releaseWorker()
|
|
case <-c.hardStop:
|
|
result.finish()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|