Decouple bounded copy pipeline and preserve ordered completion

Overlap source reads, hashing, ordered linear writes, and asynchronous cleanup with bounded buffers. Preserve conflicting output order, API behavior, and no-space diagnostics. Add an LTFS drive-buffer model and reproducible performance checks.
This commit is contained in:
Samuel CUI committed 2026-10-09 00:33:14 +08:00
1 parent 4d5dbd0b8c
commit 159d8ea4ea
42 files changed
+4996 -574

No files matched your search

+53 -6
View File
@@ -19,17 +19,54 @@ changes. Files may change between runs.
| Stage | Responsibility |
| --- | --- |
| `index` | Evaluate each needed `Item` method once, collect source facts (reusing available enumeration metadata), and resolve target paths and devices. Validate source/target aliases and overwrite rules before any target mutation. Pass the source facts unchanged to later stages. |
| `prepare` | Open the source once when content or a cached hash is needed. Read the stored signature as required by the hash policy through that descriptor. |
| `prepare` | Open the source once when content or a cached hash is needed. Read the stored signature as required by the hash policy through that descriptor. Reserve a bounded file lifetime before dequeue; preparation does not read content ahead itself. |
| `copy` | Read content once and, when enabled, compute one hash while fanning out to the targets. Complete ordinary-file metadata, cache work, synchronization and closure before final replacement; device nodes use direct output. |
| `results` | Form one `Result` per accepted item and deliver it through one callback goroutine. Fatal pipeline failures are the exception to per-item completion; see [delivery and lifecycle](#delivery-and-lifecycle). |
Indexed source facts are immutable; content-byte progress is tracked separately.
Content streams through shared immutable chunks. Hashing and writing consume independent
references in parallel; each source is read once and each enabled digest is computed once.
Random targets have independent bounded consumers of the same bytes. Linear source data reads
remain ordered through EOF, then source Close runs independently of the next read. A linear
target has one application Write lane: the next file may start after the previous file's last
Write, while target Close, hash completion and metadata/publication settle asynchronously.
This permits application overlap; the filesystem and device still decide physical scheduling.
The per-run pool allocates lazily, with at most 512 backing buffers of 1 MiB (512 MiB of content
backing, not a total RSS limit). After all consumers and completion workers exit, backing returns
to the shared reusable pool with no reference to the completed stream. Eight credits are reserved for the ordered foreground file.
For linear targets, reads stay within the next 512 MiB from the writer's consumed frontier;
this is an upper bound, not a guarantee that the window fills. Each tiny file still occupies a
1 MiB backing, and the 256 full file lifetimes also include pending completion and result delivery.
Hash queues and each random-target queue hold four chunk references; file write queues hold
references sized to the file prefix, at most 512. References share the global backing budget.
Configured source threads bound actual Read calls; configured target threads bound concurrent
file data writers. Linear devices force their corresponding thread count to one. Preparation
uses `min(max(source threads, 8), 256)` workers. Ordered reads reserve an opportunity for the
earliest unfinished source; that reservation advances at source EOF independently of Write and
Close, including with one source thread. Workers release Read permits before waiting on queues.
Random-target files reserve their writer permit in dispatch order before reading, so future files
cannot consume all backing while the active writers need it. File-lifetime admission is reserved before
job dequeue, so later preparation cannot take the required head's slot. Open descriptors scale
with admitted files and their target counts, rather than with Read permits alone.
`WithReadBuffer` continues to count input items, independently of these content limits.
### Target completion
Random ordinary outputs complete metadata, Sync and Close before rename. Linear outputs omit
per-file Sync and Close independently after data writing; metadata/cache work on their owned
temporary paths follows closure. Distinct paths may publish concurrently; linear outputs to the
same resolved path preserve publication order, including equivalent relative and absolute names.
Results keep the caller's original target names, and the exclusive-rename fallback remains in force.
A late linear Close or metadata error stops future admission once observed; it cannot retract
already started writes. Results retain each started item's actual outcome, and completion waits
for all source/target Close, hash, cache and publication dependencies.
Ordinary targets use an exclusively created `.tmp_*` file beside the resolved final path. The
old target remains in place while ACP writes the new file; the rename happens only after the
copy stage completes its other work. Success is reported after replacement, using the final
old target remains in place while ACP writes the new file; the rename happens only after that
file's content, hash, metadata and required closure complete. Success is reported after replacement, using the final
target path. A failed copy cleans up only its owned temporary file and retains cleanup errors
alongside the primary error. ACP never deletes the original to work around a rename failure.
@@ -54,7 +91,14 @@ Device nodes are written directly, without rename or metadata/cache changes, and
partial output after failure. Linear writers retain request order and perform neither
preallocation nor per-file sync. Ordinary non-linear files retain the allocation and sync
policy described under [platform behavior](#platform-behavior), with no free-space estimate.
A positive-size linear target gets one fresh free-space estimate immediately before writing.
Linear targets check an advisory free-space estimate before output creation. One observation
can cover at most 64 consecutive single-output files of at most 1 MiB each on the same mount;
each admission debits its logical size, without crediting failed-output cleanup. Below 64 MiB
of estimated remaining space, or for larger files and multiple outputs, each positive-size
target gets a fresh observation. Empty targets skip the query and invalidate the sample.
Equality permits the write. An observation failure fails that target without creating output.
This estimate reserves nothing and cannot account for filesystem or media overhead, so a
passing estimate does not guarantee that the write will fit.
Actual no-space and read-only errors stop the affected device and retain `ErrTargetNoSpace` and
`ErrTargetDropToReadonly`; `ErrTargetIO` remains an individual target failure.
@@ -280,8 +324,11 @@ when refresh is enabled, without reading or invalidating an old one.
The target entry is written before restoring restrictive permissions; all metadata is complete
before replacement.
Cache operations use the descriptors that read or wrote the content, finish before those
descriptors close, and never reopen a path. The managed key is excluded from ordinary xattr
Source cache lookup uses its content descriptor. Source refresh runs after hash and source Close
through the stable source path; a closed linear output uses its owned temporary path. Random
output cache work uses its still-open descriptor before Sync and Close. Platforms may reopen
these paths for metadata operations without rereading content. Rewrite waits for source cache
and closure before replacing its source. The managed key is excluded from ordinary xattr
copying. Cache diagnostics are aggregated in `EventSignatureCacheSummary`; they never become
copy or `Wait` errors. A filesystem without the managed attribute namespace stores nothing:
reads miss and writes are silent no-ops.
+102 -9
View File
@@ -15,6 +15,11 @@ Use the existing entrypoints below for tests, regressions and performance work:
repeated manual agent steps. Recurring regressions belong in checked-in tests or harnesses with
fixtures under the existing owner; extend those before adding another runner or script.
Keep reusable release, test and benchmark scripts and synthetic or public fixtures in this
repository. Supply machine-specific hosts, paths and private inputs through arguments or
environment variables. Keep credentials, real backups, deployment configuration and sensitive
results outside tracked source and public artifacts.
Maintain tests, harnesses and fixtures with the implementation. Update or remove obsolete
assertions and unsupported scenarios in the same change. Test meaningful behavior and failure
boundaries; avoid duplicate checks at the same boundary or assertions that merely mirror code.
@@ -26,6 +31,22 @@ Use the [performance policy](#performance-comparisons) for timings. Existing nat
acceptance and human content/consumer review remain required where applicable; record manual
steps, their results and why automation cannot establish that outcome.
### Check scope and reuse
During development, check changed behavior and affected callers with the native commands below.
A documentation-only edit does not require Go tests or benchmarks. After a failure, diagnose it
and rerun the failing boundary and affected callers; a preparation failure resumes that stage and
its dependents. Do not restart unchanged successful stages. Finish related edits before expensive
runs, and remove obsolete or duplicate requirements when their protected behavior no longer applies.
A new commit alone does not invalidate independent results. Compare relevant source, dependencies,
configuration, fixtures and environment; reuse unchanged evidence with its original identity and a
short explanation. Unknown impact requires broader checks. Native output and concise command/results
are sufficient for routine checks; release and performance evidence use the existing report tools.
Choose one final full gate for the clean integrated release source instead of duplicating the same
suite locally and in CI. New artifacts still need identity, content and checksum checks and smoke
coverage; repeat changed runtime workflows only where affected.
## Standard checks
```sh
@@ -107,7 +128,7 @@ GOMAXPROCS=4 make bench
```
Run one ordinary Go benchmark invocation per source with `-benchtime=1s -count=1 -benchmem`
across the eight existing workloads. Go calibrates iterations; do not force equal iteration
across the fourteen maintained benchmark cases. Go calibrates iterations; do not force equal iteration
counts or repeated whole-suite samples.
Compare time, bytes/op and allocations/op, recording all raw values and percentage deltas.
@@ -132,6 +153,53 @@ benefit or makes the relevant workload worse; do not accumulate ineffective alte
Release collection and evidence validation use `make performance-collect` and
`make performance-check` below. Ordinary `make check` and `make bench` require no release inputs.
## Bounded pipeline and LTFS model
`pipeline_b_test.go`, `pipeline_liveness_test.go`, `ltfs_mock_test.go`, `tape_drive_test.go` and `tape_speed_test.go` exercise the private filesystem
seam, shared content credits, ordered head admission, parallel hash/target consumers and independent
source/target Close. Keep EOF, cancellation, late no-space, same-path publication, cache and
multi-target outcomes in semantic coverage; do not assert the removed small-file prehash threshold.
The LTFS model separates three byte ledgers: application/host acceptance, driver acceptance and
physical tape completion. Close can wait for driver acceptance while physical bytes remain pending;
model Finalize drains and checks the physical ledger. ACP completion alone is not physical durability.
A single FUSE dispatcher models the observed LTFS `-s` mount: asynchronous application Close does
not imply concurrent execution inside FUSE. Host and drive buffers, minimum streaming rate,
startup, seek, restart/backhitch and injected errors are explicit scenario inputs.
The vendor-range scenario uses a 47 MB/s minimum and 140 MB/s maximum; the 100/140 MB/s scenario
is a deliberately stricter supply stress case. HP documents adaptive speed matching but does not publish the firmware control law. The mock
updates from actual drive-buffer arrivals at physical service boundaries at least 100 ms apart,
smooths over 1 s, targets 60% occupancy with a 2 s correction horizon, and limits speed changes to 20 MB/s per second with a 1 MB/s deadband.
These are explicit simulation assumptions. Physical consumption runs independently: an empty
buffer before final drain triggers the assumed 2.5 s backhitch, with no starvation grace. The
fixed-rate controls use the same full-size buffer to verify this invariant. Use ledger consistency and bounded
ownership to interpret simulated throughput; simulation cannot establish physical Tape gains.
Run the pipeline benchmark with one complete measured operation per scenario:
```sh
go test -run '^$' -bench '^BenchmarkLTFSPipeline$' -benchtime=1x -benchmem -count=1 .
```
Set `ACP_MOCK_REPORT_DIR` to retain per-operation physical-byte and buffer-occupancy CSV curves
and JSON parameters. Benchmarks retain the full 1 GiB LTFS cache and 256,000,000-byte drive buffer
and transfer 2 GiB to exceed combined cache absorption. The file-barrier reference waits for each item result; it is a scheduling control,
not a historical ACP version. Native historical comparisons use the common harness above.
`dev/ltfs_compare.py --baseline 4d5dbd0b8c06c5d53f369820ac9cff20e779821f --candidate <full-sha> --harness <full-sha> --out <new-directory> --idle`
compares the same model against published v0.2.3. The explicit reviewed harness supplies the native
smoke benchmark on both sources; its commit and file hashes are recorded with the candidate's model
hashes. The helper records the small I/O adapter patch connecting v0.2.3 to the private seam,
preserves its original scheduling and completion, checks native fallback, and saves all seven
measured cases and curves. `--scenario read-delay` selects a separate
synthetic sensitivity: 2,048 files of 1 MiB, a 20 ms first-Read delay per file, and four source
threads. This stresses content-read overlap while retaining the full physical buffers. The latency is a declared synthetic input, not a calibration
of the hardware regression. This is an instrumented historical
comparison, not an unmodified historical binary.
Retain scenario flags, model assumptions and raw results. Apply the existing matched-performance
policy and physical validation before making a hardware throughput claim.
## Platform and environment limits
Tests requiring xattrs, symlinks or permission refusal must probe host support and report skips.
@@ -141,6 +209,15 @@ for unsupported attributes. See [platform behavior](README.md#platform-behavior)
Run Linux device and filled-volume tests on Linux. The filled-volume test uses disposable tmpfs
and skips if mounting is refused. Use actual device/allocation errors to verify error mapping and
owned-output cleanup. Check the linear space estimate at the write-entry boundary.
`copy_test.go` retains the insufficient-capacity check; `target_test.go` covers exact capacity,
zero-size query avoidance and observation failure. `linear_space_test.go` covers bounded sample
reuse, logical debits, mount changes and fresh observations at capacity boundaries.
`full_linux_test.go` supplies an optimistic
estimate before real `/dev/full` ENOSPC and checks that later prefetched items remain unprocessed.
`cleanup_test.go` checks joined write, close and removal errors through the target result, while
`internal/fileio/output_test.go` owns descriptor and temporary-path cleanup checks. These bounded
fixtures verify the [current estimate limitation](README.md#target-completion), not how much
payload fits on physical media.
Linux invalid-UTF-8 filename tests cover the native filesystem boundary; portable state/report
tests cover JSON validation. Record which of these host-dependent tests ran.
@@ -198,13 +275,14 @@ later local improvements belong to a subsequent release.
moves, reconcile and review again. Freeze the version/tag and exact commit with a clean working
tree; confirm `go.mod` retains the intended module identity. Use an isolated checkout of that
commit with the workspace isolation above so ignored local files cannot become release inputs.
4. **Run gates and finish review.** Run `make release-check` with the explicit inputs below. It runs
`make check` and `make race` on an isolated exact source snapshot, including the
command/library integration tests without `-short`. Follow the
[platform and environment limits](#platform-and-environment-limits); cross-builds are not foreign
runtime acceptance, and host-dependent skips remain explicit. Review and fix until no unresolved,
unaccepted finding remains. A source fix requires a new clean commit and verification of the
final source; record exact passes, failures and unrun gates rather than inheriting old results.
4. **Finish review, then run the final source gate.** Review and fix with affected checks until no
unresolved, unaccepted finding remains, following [check scope and reuse](#check-scope-and-reuse).
Run `make release-check` once on the clean integrated source with the explicit inputs below. It
runs `make check` and `make race` on an isolated snapshot, including command/library integration
without `-short`. Follow the [platform and environment limits](#platform-and-environment-limits);
cross-builds are not foreign runtime acceptance, and host-dependent skips remain explicit.
Record passes, failures, reused evidence and unrun gates against their actual inputs. A fix after
this gate refreshes the affected checks; a reviewed independent diff can retain unrelated passes.
5. **Approve publication.** Review the final diff, commit message, tag target and Release text.
Inspect and scan any distributed binaries or archives for contents, licenses and private-path leakage,
and record their checksums. Include shipped generated code and dependency sources in content
@@ -269,6 +347,21 @@ tests are removed from those temporary benchmark builds. The collector measures
then the candidate, on the same temporary filesystem under the [performance comparison policy](#performance-comparisons).
Fixture setup and cleanup add to elapsed time beyond the benchmark duration.
`BenchmarkCopyWorkload` retains ordinary one-target and three-target disk workloads and adds
linear-target small (256 files of 256 KiB), large (one 64 MiB file), and mixed (128 files
of 256 KiB interleaved with four 8 MiB files) workloads in buffered and mapped source modes.
Linear cases use YATM Archive's `HashReadRefresh` policy and serialized target scheduling.
Every iteration reads and hashes the source content. Source fixtures remain in place across
Go calibration and measured iterations, so filesystem caches and existing signature xattrs
may be warm; unchanged signature entries avoid redundant cache writes. These are warm-cache
overhead comparisons, not cold-source measurements.
These local filesystem measurements expose ACP overhead; they do not measure Tape throughput,
repositions, LTFS finalization or Restore. Select affected cases with Go's `-bench` filter during
investigation; the paired release collector requires the complete maintained inventory.
Keep a change only when throughput or reposition activity clearly improves with no confirmed
regression in the other metric or correctness. Inconclusive changes are reverted; stop when no
evidence-backed simple candidate remains or the remaining constraint belongs to LTFS/hardware.
The defaults are four CPUs, CGO disabled and Go's normal GC. `--cpu` accepts a positive CPU count;
Go's unsuffixed benchmark names for `--cpu 1` are accepted. The read fixtures contain exactly
64 MiB and the refresh fixtures exactly 4 KiB; fixture creation verifies those sizes before
@@ -291,7 +384,7 @@ Any other changed file requires matching measurements; this exception never chan
Each source log must contain one completed row for every workload, with `ns/op`, `B/op` and
`allocs/op`, followed by Go's successful `PASS` and package `ok` summary. Missing or duplicate rows,
missing metrics, failed or unfinished commands, changed identities and modified output fail
validation. The comparison saves `comparison.json` and prints all 24 raw metric pairs, percentage
validation. The comparison saves `comparison.json` and prints all 42 raw metric pairs, percentage
deltas and review flags. Apply the [same review policy](#performance-comparisons) locally and in
CI, retaining the original report and any investigation output outside maintained documentation.
+6 -3
View File
@@ -51,8 +51,10 @@ type StreamCopyer struct {
getDevice func(in string) (string, error)
availableSpace func(mountPoint string) (int64, error)
targetDirs map[string]string // Indexed parent paths; Submit is their sole owner.
linearSpace spaceEstimate // Owned by the single linear writer.
linearTargetEnded uint32
signatures *signatureCache
filesystem transferFilesystem
// order numbers submitted items; a single submitter keeps it race-free.
order uint64
@@ -198,8 +200,8 @@ func (c *StreamCopyer) Submit(items ...Item) error {
return nil
}
// Close ends the feed and drains the run: every item already submitted finishes, its cache entry
// is published before its descriptor closes, the remaining results are flushed, and the run's
// Close ends the feed and drains the run: every item already submitted finishes
// and all dependent cache and Close work settle, the remaining results are flushed, and the run's
// cache summary is reported. It returns the flush error, and it is safe to call more than once.
func (c *StreamCopyer) Close() error {
c.closeOnce.Do(func() {
@@ -373,7 +375,7 @@ func (c *StreamCopyer) setError(err error) {
}
func (c *StreamCopyer) endLinearTarget(err error) {
if !c.toDevice.linear || !checkErrorAbort(err) {
if !c.toDevice.linear || !checkErrorAbort(mappingError(err)) {
return
}
atomic.StoreUint32(&c.linearTargetEnded, 1)
@@ -406,6 +408,7 @@ func (c *StreamCopyer) run(ctx context.Context) error {
}
for job := range prepared {
job.finishSource()
job.releaseSlot()
}
for range copyed {
}
+98
View File
@@ -6,8 +6,106 @@ import (
"os"
"path/filepath"
"testing"
mapset "github.com/deckarep/golang-set/v2"
"github.com/samuelncui/acp/internal/fileio"
)
func TestTargetFailurePreservesWriteCloseAndRemoveErrors(t *testing.T) {
// Match the fileio discard fixture: a closed descriptor and owned nonempty directory fail independently.
root := t.TempDir()
target := writeSourceFile(t, root, "target", []byte("original"))
owned := filepath.Join(root, ".tmp_owned")
child := writeSourceFile(t, owned, "child", nil)
file, err := os.Open(child)
if err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
out := &fileio.Output{File: file, Path: target, Temporary: owned}
copyer := newTestStream(t, SetToDevice(LinearDevice(true)))
job := &writeJob{baseJob: &baseJob{
copyer: copyer, item: newFixtureItem("source", target), path: "source",
stat: &stat{size: 1}, targets: []string{target},
}}
// The writer must retain its primary failure when both cleanup operations fail, and drain queued buffers.
chunks := make(chan *chunkBuffer, 2)
var buffers []*chunkBuffer
for range 2 {
chunk := acquireChunk()
chunk.data = chunk.data[:1]
chunk.data[0] = 'x'
buffers = append(buffers, chunk)
chunks <- chunk
}
close(chunks)
var readErr error
transfer := newTransfer(copyer, job, newReadAhead(copyer), 0)
targetState := &targetTransfer{spec: targetSpec{name: target, path: target, device: root}, output: &nativeTransferOutput{copyer: copyer, output: out}}
for chunk := range chunks {
if targetState.err != nil {
chunk.release()
continue
}
targetState.err = writeChunk(out.File, chunk)
}
targetState.err = errors.Join(targetState.err, readErr)
transfer.finishTarget(targetState, mapset.NewSet[string]())
result := job.result()
if result.Err != nil || len(result.Targets) != 1 || result.Targets[0].Path != target {
t.Fatalf("unexpected result: %+v", result)
}
failure := result.Targets[0].Err
var cleanup *fileio.CleanupError
if !errors.Is(failure, os.ErrClosed) || !errors.As(failure, &cleanup) || cleanup.Path != owned {
t.Fatalf("target lost primary or cleanup error: %v", failure)
}
// Inspect every joined branch: errors.As alone would only find the first of the two closed-file errors.
operations := make(map[string]*os.PathError)
var inspect func(error)
inspect = func(err error) {
switch err := err.(type) {
case *os.PathError:
operations[err.Op] = err
case interface{ Unwrap() []error }:
for _, child := range err.Unwrap() {
inspect(child)
}
case interface{ Unwrap() error }:
inspect(err.Unwrap())
}
}
inspect(failure)
for _, op := range []string{"write", "close"} {
if err := operations[op]; err == nil || err.Path != child || !errors.Is(err, os.ErrClosed) {
t.Errorf("missing original %s error: %v", op, err)
}
}
if err := operations["remove"]; err == nil || err.Path != owned || !errors.Is(failure, err.Err) {
t.Errorf("missing original remove error: %v", err)
}
// Failed removal retains ownership and never touches the original target.
if out.File != nil || out.Temporary != owned || len(chunks) != 0 {
t.Fatalf("cleanup ownership = %+v; queued chunks = %d", out, len(chunks))
}
for _, chunk := range buffers {
if chunk.refs != 0 {
t.Errorf("queued buffer retains %d references", chunk.refs)
}
}
if content, err := os.ReadFile(target); err != nil || string(content) != "original" {
t.Fatalf("original target changed: %q / %v", content, err)
}
if _, err := os.Stat(child); err != nil {
t.Fatalf("failed removal lost its owned path: %v", err)
}
}
func TestCleanupRemovesTargetAfterMetadataFailure(t *testing.T) {
// Refuse metadata after a real copy; the exclusive output must disappear before the result.
root := t.TempDir()
+7 -6
View File
@@ -169,6 +169,7 @@ func TestReportKeepsFailuresInsideTheFileRow(t *testing.T) {
}
func TestReportAccountsForEveryEntryAfterAGracefulStop(t *testing.T) {
// Select more work than the bounded data, result and event queues can finish before delivery.
root := t.TempDir()
sourceDir := filepath.Join(root, "source")
targetDir := filepath.Join(root, "target")
@@ -176,18 +177,17 @@ func TestReportAccountsForEveryEntryAfterAGracefulStop(t *testing.T) {
t.Fatal(err)
}
const total = 24
// Tiny files must still leave a backlog when asynchronous reporting delivers its first row.
const total = 1024
sources := make([]string, 0, total)
for index := 0; index < total; index++ {
name := fmt.Sprintf("%02d.txt", index)
sources = append(sources, writeEntryFile(t, sourceDir, name, []byte(strings.Repeat(name, 32))))
}
// The first terminal report cancels admission while bounded queues still retain later files.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// The shell emits terminal rows only, so the stop trigger is the first row: one completed
// file has its row published while later files are still queued in the pipeline.
var once sync.Once
stopOnFirstRow := func(event acp.Event) {
if _, ok := event.(*acp.EventUpdateJob); ok {
@@ -195,8 +195,8 @@ func TestReportAccountsForEveryEntryAfterAGracefulStop(t *testing.T) {
}
}
// One result per delivery publishes every completed row immediately, and one writer
// serializes the copies, so the stop lands while the run is still working.
// Per-row delivery cancels while bounded admission still leaves queued work; asynchronous
// completion may already have admitted multiple files even with one data writer.
collector, runErr := runJobs(
t,
ctx,
@@ -220,6 +220,7 @@ func TestReportAccountsForEveryEntryAfterAGracefulStop(t *testing.T) {
t.Fatalf("report files = %d, want %d", len(shape.Files), len(sources))
}
// Every selected source has exactly one completed or abandoned row.
stopped, completed := 0, 0
rows := make(map[string]int, len(shape.Files))
for _, row := range shape.Files {
+67 -255
View File
@@ -2,86 +2,59 @@ package acp
import (
"context"
"errors"
"fmt"
"hash"
"io"
"sync"
"sync/atomic"
"time"
mapset "github.com/deckarep/golang-set/v2"
sha256 "github.com/minio/sha256-simd"
"github.com/sirupsen/logrus"
)
const (
batchSize = 1 * 1024 * 1024
)
const batchSize = 1 * 1024 * 1024
var (
sha256Pool = &sync.Pool{New: func() interface{} { return sha256.New() }}
)
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.
// chunkBuffer shares immutable content; the final reference returns one backing allocation.
type chunkBuffer struct {
data []byte
refs int32
data []byte
refs int32
owner *readAhead
future bool
}
var chunkPool = sync.Pool{
New: func() interface{} {
return &chunkBuffer{data: make([]byte, batchSize)}
},
}
// New is also the private allocation seam used by resource-lifetime checks.
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)
if atomic.AddInt32(&c.refs, -1) != 0 {
return
}
if c.owner != nil {
c.owner.release(c)
return
}
chunkPool.Put(c)
}
// copy admits bounded file lifetimes while data lanes and completion run independently.
func (c *StreamCopyer) copy(ctx context.Context, prepared <-chan *writeJob) <-chan *baseJob {
// Every wait and handoff in this stage runs on a context that never cancels, so a stop
// can never interrupt an item in flight.
drained := context.WithoutCancel(ctx)
ch := make(chan *baseJob, 128)
var copying sync.WaitGroup
done := make(chan struct{})
var reporting sync.WaitGroup
defer func() {
go c.wrap(drained, func() {
copying.Wait()
close(done)
reporting.Wait()
close(ch)
})
}()
// One owner dispatches transfer order; public result delivery remains independent.
out := make(chan *baseJob, 128)
ahead := newReadAhead(c)
writing := make(chan struct{}, max(c.toDevice.threads, 1))
exhausted := mapset.NewSet[string]()
cntr := new(counter)
reporting.Add(1)
go c.wrap(drained, func() {
defer reporting.Done()
done := make(chan struct{})
reported := make(chan struct{})
go c.wrap(context.WithoutCancel(ctx), func() {
defer close(reported)
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
@@ -95,212 +68,51 @@ func (c *StreamCopyer) copy(ctx context.Context, prepared <-chan *writeJob) <-ch
}
})
noSpaceDevices := mapset.NewSet[string]()
for idx := 0; idx < c.toDevice.threads; idx++ {
copying.Add(1)
go c.wrap(drained, 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 {
// An item this stage cannot start is marked and forwarded: the reporting
// stage fails it, which keeps exactly one terminal callback per item.
if c.markUnstarted(ctx, job.baseJob) {
job.finishSource()
c.publish(ch, job.baseJob)
continue
}
c.write(drained, job, ch, cntr, noSpaceDevices)
}
})
}
return ch
}
func (c *StreamCopyer) write(ctx context.Context, job *writeJob, ch chan<- *baseJob, cntr *counter, noSpaceDevices mapset.Set[string]) {
// Register all lifetimes before starting consumers; a preparation panic must release their gates.
var wg sync.WaitGroup
var readErr error
chans := make([]chan *chunkBuffer, 0, len(job.targets)+1)
defer func() {
if value := recover(); value != nil {
readErr = panicError("copy item", value)
job.itemError = readErr
c.setError(readErr)
c.stopHard()
}
for _, ch := range chans {
close(ch)
}
wg.Wait()
// Source release also runs if cache publication panics inside this finalization scope.
func() {
defer func() {
if err := job.finishSource(); err != nil {
job.itemError = errors.Join(job.itemError, err)
}
}()
c.refreshCacheEntry(job.source, job.path, job.baseJob, false)
// Managed children retain their own resources until completion; ordered turns end at data EOF.
go c.wrap(context.WithoutCancel(ctx), func() {
var children sync.WaitGroup
defer func() {
// Return backing only after all data, completion and cleanup owners have exited.
children.Wait()
ahead.close()
close(done)
<-reported
close(out)
}()
c.publish(ch, job.baseJob)
}()
// Record the write time; source metadata remains the snapshot owned by indexing.
job.startWrite()
if job.skipContent {
atomic.AddInt64(&cntr.files, 1)
return
}
if job.cacheHit {
atomic.AddInt64(&cntr.files, 1)
atomic.AddInt64(&cntr.bytes, job.stat.size)
return
}
// Track progress and close every consumer after the source reader finishes.
atomic.AddInt64(&cntr.files, 1)
// cacheGate releases the target writers once the item's content hash is complete, so a target
// publishes its cache entry through its own descriptor before it finalises. It stays nil when
// the policy produces no hash, because then no target has an entry to publish.
var cacheGate chan struct{}
if c.signatures != nil && c.hashPolicy.refreshesCache() {
cacheGate = make(chan struct{})
}
// Add hashing as another consumer of the shared source stream.
if c.hashPolicy.producesHash() {
ch := make(chan *chunkBuffer, 4)
chans = append(chans, ch)
wg.Add(1)
go c.wrap(ctx, func() {
defer wg.Done()
defer func() {
for chunk := range ch {
chunk.release()
}
}()
// The hash is complete, so the target writers may publish their cache entries. The
// release is deferred: a panic in the hasher must not leave a writer blocked on the
// gate, because Wait and Close would then never return after a recovered panic.
if cacheGate != nil {
defer close(cacheGate)
ready := make(chan struct{})
close(ready)
var readTurn, writeTurn <-chan struct{} = ready, ready
var offset int64
publication := newPublicationOrder()
for job := range prepared {
t := newTransfer(c, job, ahead, offset)
if job.stat != nil {
offset += max(job.stat.size, 0)
}
defer func() {
if value := recover(); value != nil {
err := panicError("hash consumer", value)
c.setError(err)
c.stopHard()
}
}()
sha := sha256Pool.Get().(hash.Hash)
defer sha256Pool.Put(sha)
sha.Reset()
for chunk := range ch {
func() { defer chunk.release(); _, _ = sha.Write(chunk.data) }()
if c.fromDevice.linear {
t.readTurn, readTurn = readTurn, t.readDone
}
// A stopped read describes only part of the source, so it has no content hash.
if readErr != nil {
job.setHash(nil)
} else {
job.setHash(sha.Sum(nil))
if c.toDevice.linear {
t.writeTurn, writeTurn = writeTurn, t.writeDone
}
})
}
t.reservePublications(publication)
// Open targets before reading once; each failed target leaves independent targets available.
targetWriters := 0
for _, target := range job.outputs {
if noSpaceDevices.Contains(target.device) {
job.fail(target.name, ErrTargetNoSpace)
continue
}
out, err := c.prepareTarget(job, target)
if err != nil {
c.targetFailed(job.baseJob, target.name, target.device, err, noSpaceDevices)
continue
}
func() {
transferred := false
defer func() {
if !transferred {
_ = out.Discard()
// Reserve random-target writers in source dispatch order before their readers can hold backing.
// Linear targets instead keep speculative readers ahead of their single ordered data lane.
if !c.toDevice.linear {
select {
case writing <- struct{}{}:
t.admitted = true
case <-c.hardStop:
}
}()
chunks := make(chan *chunkBuffer, 4)
chans = append(chans, chunks)
wg.Add(1)
go c.wrap(ctx, func() {
defer wg.Done()
c.consumeTarget(job, target, out, chunks, cacheGate, &readErr, noSpaceDevices)
}
children.Add(1)
go c.wrap(context.WithoutCancel(ctx), func() {
defer children.Done()
defer job.releaseSlot()
t.run(ctx, out, writing, cntr, exhausted)
})
transferred = true
targetWriters++
}()
}
// Read the source only when at least one target or hash consumer needs it.
if len(chans) == 0 {
return
}
_, readErr = c.streamCopy(chans, job.reader, &cntr.bytes)
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.logf(logrus.ErrorLevel, "copy failed, source= %q, %v", 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 *StreamCopyer) streamCopy(dsts []chan *chunkBuffer, src io.ReadCloser, bytes *int64) (int64, error) {
// Each iteration owns one producer reference, with no deferred resources accumulating in the loop.
var copied int64
for {
n, err := c.streamChunk(dsts, src)
copied += int64(n)
atomic.AddInt64(bytes, int64(n))
if err != nil {
return copied, err
}
if n < batchSize {
return copied, nil
}
}
}
func (c *StreamCopyer) streamChunk(dsts []chan *chunkBuffer, src io.Reader) (int, error) {
// Acquire and release in the same scope, including a panic in an injected reader.
chunk := acquireChunk()
defer chunk.release()
n, err := io.ReadFull(src, chunk.data)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
return 0, fmt.Errorf("read source failed, %w", err)
}
// Every successful handoff transfers exactly one reference to a consumer.
chunk.data = chunk.data[:n]
for _, ch := range dsts {
receipt := chunk.retain()
select {
case ch <- receipt:
case <-c.hardStop:
receipt.release()
return n, fmt.Errorf("copy stopped by pipeline failure")
}
}
return n, nil
})
return out
}
+72
View File
@@ -0,0 +1,72 @@
package acp
import (
"bytes"
"context"
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"testing"
)
func TestLinearCopyChunkBoundaries(t *testing.T) {
// Exercise complete files around EOF with both native source readers.
for _, mode := range []ReadMode{ReadBuffered, ReadMapped} {
t.Run(fmt.Sprint(mode), func(t *testing.T) {
// Reusing one destination makes the final content depend on linear request order.
root := t.TempDir()
target := filepath.Join(root, "target")
contents := [][]byte{nil, bytes.Repeat([]byte("x"), batchSize), bytes.Repeat([]byte("t"), batchSize+17)}
items := make([]Item, 0, len(contents))
for index, content := range contents {
source := writeSourceFile(t, root, fmt.Sprintf("source-%d", index), content)
items = append(items, &SimpleJob{Path: source, Dsts: []string{target}})
}
// Wait includes consumer closure, target finalization and complete result delivery.
callback, batches := collectResults()
if err := runStream(context.Background(), callback, items, Overwrite(true), WithHashPolicy(HashReadRefresh),
SetFromDevice(WithReadMode(mode)), SetToDevice(LinearDevice(true))); err != nil {
t.Fatal(err)
}
// Every accepted item reports its complete size and hash exactly once.
seen := make(map[Item]bool)
for _, batch := range batches() {
for _, result := range batch {
if seen[result.Job] {
t.Fatalf("duplicate result for %v", result.Job)
}
seen[result.Job] = true
index := -1
for i, item := range items {
if item == result.Job {
index = i
break
}
}
if index < 0 {
t.Fatalf("unexpected result for %v", result.Job)
}
want := sha256.Sum256(contents[index])
if result.Err != nil || result.Size != int64(len(contents[index])) || !bytes.Equal(result.SHA256, want[:]) {
t.Fatalf("incomplete result: %+v", result)
}
if len(result.Targets) != 1 || result.Targets[0].Err != nil {
t.Fatalf("target result: %+v", result.Targets)
}
}
}
if len(seen) != len(items) {
t.Fatalf("results=%d want=%d", len(seen), len(items))
}
// The last submitted file owns the destination after all target writers have closed.
got, err := os.ReadFile(target)
if err != nil || !bytes.Equal(got, contents[len(contents)-1]) {
t.Fatalf("final ordered target: bytes=%d err=%v", len(got), err)
}
})
}
}
+32 -43
View File
@@ -134,6 +134,20 @@ func runWriteJob(copyer *StreamCopyer, job *writeJob) error {
return copyer.Wait()
}
// write drives one synthetic reader through the same transfer implementation as the stream.
func (c *StreamCopyer) write(ctx context.Context, job *writeJob, out chan<- *baseJob, cntr *counter, exhausted mapset.Set[string]) {
t := newTransfer(c, job, newReadAhead(c), 0)
t.reservePublications(newPublicationOrder())
t.run(ctx, out, make(chan struct{}, 1), cntr, exhausted)
}
// acquireChunk supplies a standalone consumer fixture without a second production pool path.
func acquireChunk() *chunkBuffer {
chunk := chunkPool.New().(*chunkBuffer)
chunk.refs = 1
return chunk
}
func TestRunCopiesEmptyFile(t *testing.T) {
tests := []struct {
name string
@@ -199,7 +213,7 @@ func TestWriteReportsTargetlessReadFailureAsItemFailure(t *testing.T) {
item: item,
path: "source",
stat: &stat{size: 1},
}, &failingReadCloser{err: readErr}, false)
}, &failingReadCloser{err: readErr})
completed := make(chan *baseJob, 1)
// An item with no target outcome to report could not be processed at all.
@@ -244,7 +258,7 @@ func TestWriteKeepsTargetOutcomesWhenTheReadAlsoFails(t *testing.T) {
path: filepath.Join(root, "source"),
stat: &stat{size: 1, mode: 0o644},
targets: []string{refused},
}, &failingReadCloser{err: readErr}, false)
}, &failingReadCloser{err: readErr})
if err := runWriteJob(copyer, job); err != nil {
t.Fatalf("pipeline failed: %v", err)
@@ -266,26 +280,6 @@ func TestWriteKeepsTargetOutcomesWhenTheReadAlsoFails(t *testing.T) {
}
}
func TestWriteJobWaitConsumedEndsOnConsumptionOrHardStop(t *testing.T) {
copyer := newTestStream(t)
// Model a linear source whose reader has already moved to the Copy stage. A stopped
// caller must not release the wait: the consumer owns the reader and is what reports the
// item, so only consumption ends the wait.
consumed := newWriteJob(&baseJob{copyer: copyer}, new(trackingReadCloser), true)
consumed.finishSource()
if !consumed.waitConsumed() {
t.Fatal("waitConsumed() = false after the consumer took the reader")
}
// A fatal pipeline failure is the other exit, so the wait can never deadlock a stop.
copyer.stopHard()
blocked := newWriteJob(&baseJob{copyer: copyer}, new(trackingReadCloser), true)
if blocked.waitConsumed() {
t.Fatal("waitConsumed() = true after a fatal pipeline failure")
}
}
func TestCopyReportsPrefetchedItemsAfterCancellation(t *testing.T) {
// Queue one prefetched reader before starting an already-canceled Copy stage.
ctx, cancel := context.WithCancel(context.Background())
@@ -296,7 +290,7 @@ func TestCopyReportsPrefetchedItemsAfterCancellation(t *testing.T) {
item := newFixtureItem("source")
fixture := newStreamFixture(item)
copyer.onResults = fixture.onResults
job := newWriteJob(&baseJob{copyer: copyer, item: item, path: "source"}, reader, true)
job := newWriteJob(&baseJob{copyer: copyer, item: item, path: "source"}, reader)
prepared := make(chan *writeJob, 1)
prepared <- job
close(prepared)
@@ -310,9 +304,6 @@ func TestCopyReportsPrefetchedItemsAfterCancellation(t *testing.T) {
if reader.closed != 1 {
t.Fatalf("reader closed %d times, want 1", reader.closed)
}
if !job.waitConsumed() {
t.Fatal("linear source was not notified that the reader was consumed")
}
if result, err := item.terminal(t); !errors.Is(err, context.Canceled) {
t.Fatalf("terminal outcome = %#v / %v, want %v", result, err, context.Canceled)
}
@@ -327,7 +318,7 @@ func TestWriteReturnsAfterHardStopWithoutPublishing(t *testing.T) {
copyer: copyer,
item: newFixtureItem("source"),
stat: &stat{},
}, reader, false)
}, reader)
done := make(chan struct{})
go func() {
copyer.write(context.Background(), job, make(chan *baseJob), new(counter), mapset.NewSet[string]())
@@ -403,7 +394,7 @@ func TestWriteFailureDrainsBuffersAndTargets(t *testing.T) {
path: filepath.Join(root, "source"),
stat: &stat{size: batchSize, mode: 0o644},
targets: targets,
}, &failingContentReader{err: readErr, batches: 1}, false)
}, &failingContentReader{err: readErr, batches: 1})
// The pipeline must drain every writer, report exactly one outcome, and return.
done := make(chan error, 1)
@@ -511,7 +502,7 @@ func TestLinearTargetStopsWhenDiskUsageEstimateIsInsufficient(t *testing.T) {
copyer: copyer, item: item, path: "source",
stat: &stat{size: size}, targets: []string{target},
outputs: []targetSpec{{name: target, path: target, device: root}},
}, io.NopCloser(bytes.NewReader(nil)), false)
}, io.NopCloser(bytes.NewReader(nil)))
completed := make(chan *baseJob, 1)
// The hardware-backed estimate must stop a linear target before the oversized write starts.
@@ -583,27 +574,25 @@ func TestSubmitRejectsANilItem(t *testing.T) {
// TestStreamCopyReleasesAChunkHandoffAfterHardStop pins the fatal-failure rule for the read
// handoff: a stage that stopped consuming cannot leave the reader blocked on a chunk.
func TestStreamCopyReleasesAChunkHandoffAfterHardStop(t *testing.T) {
// A real transfer producer must relinquish its borrowed buffer when its receiver stops.
trackChunkPool(t)
copyer := newTestStream(t)
// The consumer never drains, so the reader blocks on its handoff until the hard stop.
consumers := []chan *chunkBuffer{make(chan *chunkBuffer)}
done := make(chan error, 1)
go func() {
_, err := copyer.streamCopy(consumers, io.NopCloser(strings.NewReader("fixture")), new(int64))
done <- err
}()
job := newWriteJob(&baseJob{copyer: copyer, stat: &stat{size: 7}}, io.NopCloser(strings.NewReader("fixture")))
transfer := newTransfer(copyer, job, newReadAhead(copyer), 0)
transfer.chunks = make(chan *chunkBuffer)
go copyer.wrap(context.Background(), transfer.readContent)
time.Sleep(50 * time.Millisecond)
copyer.stopHard()
// Both content completion and asynchronous source closure must finish before inspection.
select {
case err := <-done:
if err == nil {
t.Fatal("streamCopy() error = nil, want the pipeline failure")
}
case <-transfer.readDone:
case <-time.After(5 * time.Second):
t.Fatal("streamCopy stayed blocked on a chunk handoff after a hard stop")
t.Fatal("source stayed blocked on a chunk handoff")
}
<-transfer.sourceDone
if transfer.readErr == nil {
t.Fatal("missing hard-stop read error")
}
}
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Compare committed ACP pipelines with one LTFS model and explicit historical I/O adapters."""
import argparse
import difflib
import json
import os
from pathlib import Path
import platform
import re
import tempfile
from release import (commit, digest, go_environment, harness_files, private_output,
require, run, save, snapshot)
BASELINES = {
"4d5dbd0b8c06c5d53f369820ac9cff20e779821f", # Published v0.2.3.
}
MODEL_FILES = ("ltfs_mock_test.go", "tape_drive_test.go", "tape_speed_test.go", "ltfs_benchmark_test.go")
SELECTOR = "^BenchmarkLTFSPipeline$/^(native47|stress100)$/^(small|large|mixed|small-delayed-source)$/^pipeline$"
def replace_once(text, old, new):
require(text.count(old) == 1, "Historical I/O adapter anchor changed.")
return text.replace(old, new)
def adapt(source, adapter):
"""Change only native I/O dispatch; retain historical scheduling and completion."""
changes = {
"acp.go": [("type StreamCopyer struct {", "type StreamCopyer struct {\n\tfilesystem transferFilesystem")],
"prepare.go": [("openSourceContent(job.path, mode, job.stat.info)", "c.fs().Open(job.path, mode, job.stat.info)")],
"copy.go": [("c.prepareTarget(job, target)", "c.fs().Create(job, target)")],
"target.go": [
("target targetSpec, out *fileio.Output, chunks", "target targetSpec, out transferOutput, chunks"),
("writeChunk(out.File, chunk)", "writeChunk(out, chunk)"),
("c.refreshCacheEntry(out.File, target.name, job.baseJob, true)", "out.Cache(job.baseJob, c.toDevice.linear)"),
("restoreTarget(out.Temporary, job.stat)", "out.Restore(job.stat)"),
("out.File.Sync()", "out.Sync()"),
("commitTarget(out, c.createFlag&os.O_TRUNC != 0)", "out.Commit(c.createFlag&os.O_TRUNC != 0)"),
],
}
patch = []
for name, replacements in changes.items():
path = source / name
before = path.read_text()
after = before
for old, new in replacements:
after = replace_once(after, old, new)
path.write_text(after)
patch.extend(difflib.unified_diff(before.splitlines(True), after.splitlines(True), "a/" + name, "b/" + name))
# Historical targets still own an open descriptor at Cache, including linear outputs.
adapter = re.sub(r"(?ms)^func \(o \*nativeTransferOutput\) Cache\(.*?^}\n", """func (o *nativeTransferOutput) Cache(job *baseJob, _ bool) {
\to.copyer.refreshCacheEntry(o.output.File, o.target.name, job, true)
}
""", adapter)
require("refreshCachePath" not in adapter, "Adapter must preserve historical descriptor cache writes.")
(source / "filesystem.go").write_text(adapter)
patch.extend(difflib.unified_diff([], adapter.splitlines(True), "/dev/null", "b/filesystem.go"))
return "".join(patch)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--baseline", action="append", required=True)
parser.add_argument("--candidate", required=True)
parser.add_argument("--harness", required=True)
parser.add_argument("--out", required=True)
parser.add_argument("--idle", action="store_true")
parser.add_argument("--scenario", choices=("ordinary", "read-delay"), default="ordinary")
args = parser.parse_args()
selector, expected = SELECTOR, 7
if args.scenario == "read-delay":
selector, expected = "^BenchmarkLTFSPipeline$/^stress100$/^medium-delayed-read$/^pipeline$", 1
require(args.idle, "Stop other builds and benchmarks before measuring.")
require(set(args.baseline) <= BASELINES, "Historical adapter is reviewed only for the listed exact revisions.")
repo = Path.cwd()
for revision in [*args.baseline, args.candidate, args.harness]:
commit(repo, revision)
output = private_output(repo, args.out)
files = {name: run(["git", "show", args.candidate + ":" + name], cwd=repo) for name in MODEL_FILES}
adapter = run(["git", "show", args.candidate + ":filesystem.go"], cwd=repo).decode()
native = harness_files(repo, args.harness)
record = {"complete": False, "candidate": args.candidate, "baselines": args.baseline,
"harness": args.harness, "harness_files": {name: digest(data) for name, data in native.items()},
"scenario": args.scenario, "model_files": {name: digest(data) for name, data in files.items()},
"collector_sha256": digest(Path(__file__).read_bytes()), "runs": [],
"note": "Historical snapshots use recorded I/O-only adapters. Scheduling and completion remain historical. Model gains are not hardware gains."}
save(output / "comparison.json", record)
with tempfile.TemporaryDirectory(prefix=".ltfs-compare-", dir=output.parent) as directory:
work = Path(directory)
temporary = work / "tmp"
temporary.mkdir()
for revision in [*args.baseline, args.candidate]:
label = revision[:12]
source = work / label
tree = snapshot(repo, revision, source)
for path in source.glob("*_test.go"):
path.unlink()
patch = ""
if revision in BASELINES:
patch = adapt(source, adapter)
(output / (label + ".adapter.patch")).write_text(patch)
for name, data in {**native, **files}.items():
(source / name).write_bytes(data)
go, env, info = go_environment(source, temporary, cpu=4)
env["ACP_MOCK_REPORT_DIR"] = str(output / label)
# Native fallback exercises the mechanical adapter before timed model comparisons.
smoke = [go, "test", "-run=^$", "-bench=^BenchmarkCopyWorkload$/^SmallOneTarget$", "-benchtime=1x", "-count=1", "-cpu=4", "."]
run(smoke, cwd=source, env=env, log=output / (label + ".native.log"))
command = [go, "test", "-run=^$", "-bench=" + selector, "-benchtime=1x", "-count=1", "-benchmem", "-cpu=4", "-p=1", "-timeout=15m", "."]
entry = {"revision": revision, "tree": tree, "adapter_sha256": digest(patch.encode()),
"command": list(map(str, command)), "go": info, "host": platform.platform(),
"filesystem_device": temporary.stat().st_dev, "load": os.getloadavg(), "complete": False}
record["runs"].append(entry)
save(output / "comparison.json", record)
data = run(command, cwd=source, env=env, log=output / (label + ".bench"))
names = re.findall(r"^BenchmarkLTFSPipeline/([^\s]+)\s+1\s+", data.decode(), re.M)
require(len(names) == expected and len(set(names)) == expected and "\nPASS\n" in data.decode(), "Incomplete model benchmark coverage.")
entry.update(complete=True, output_sha256=digest(data))
save(output / "comparison.json", record)
record["complete"] = True
save(output / "comparison.json", record)
print(json.dumps({"complete": True, "out": str(output)}))
if __name__ == "__main__":
main()
+3
View File
@@ -31,6 +31,9 @@ BENCHMARKS = (
"BenchmarkRefreshSignatureUnchanged", "BenchmarkRefreshSignatureChanged",
"BenchmarkCopyWorkload/LargeOneTarget", "BenchmarkCopyWorkload/LargeThreeTargets",
"BenchmarkCopyWorkload/SmallOneTarget", "BenchmarkCopyWorkload/SmallThreeTargets",
"BenchmarkCopyWorkload/LinearSmallBuffered", "BenchmarkCopyWorkload/LinearSmallMapped",
"BenchmarkCopyWorkload/LinearLargeBuffered", "BenchmarkCopyWorkload/LinearLargeMapped",
"BenchmarkCopyWorkload/LinearMixedBuffered", "BenchmarkCopyWorkload/LinearMixedMapped",
)
METRICS = ("ns/op", "B/op", "allocs/op")
SIDES = ("baseline", "candidate")
+112
View File
@@ -0,0 +1,112 @@
import contextlib
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest import mock
import ltfs_compare
from release import GateError, digest
class LTFSCompareTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(prefix="acp-ltfs-compare-test-")
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.baseline = "4d5dbd0b8c06c5d53f369820ac9cff20e779821f"
self.candidate = "a" * 40
self.harness = "b" * 40
self.adapter = (Path(__file__).resolve().parents[1] / "filesystem.go").read_text()
self.arguments = ["ltfs_compare.py", "--baseline", self.baseline, "--candidate", self.candidate,
"--harness", self.harness, "--out", str(self.root / "out"), "--idle"]
def snapshot(self, repo, revision, source):
source.mkdir()
# Only the reviewed historical dispatch boundaries are needed for this adapter fixture.
files = {"acp.go": "type StreamCopyer struct {\n}\n",
"prepare.go": "openSourceContent(job.path, mode, job.stat.info)\n",
"copy.go": "c.prepareTarget(job, target)\n",
"target.go": "target targetSpec, out *fileio.Output, chunks\n"
"writeChunk(out.File, chunk)\n"
"c.refreshCacheEntry(out.File, target.name, job.baseJob, true)\n"
"restoreTarget(out.Temporary, job.stat)\n"
"out.File.Sync()\n"
"commitTarget(out, c.createFlag&os.O_TRUNC != 0)\n"}
for name, text in files.items():
(source / name).write_text(text)
return revision
def test_harness_is_required_before_any_collection(self):
arguments = self.arguments.copy()
start = arguments.index("--harness")
del arguments[start:start + 2]
with mock.patch("sys.argv", arguments), \
mock.patch.object(ltfs_compare, "private_output") as output, contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as raised:
ltfs_compare.main()
self.assertEqual(raised.exception.code, 2)
output.assert_not_called()
def test_unreviewed_baseline_stops_before_source_access(self):
arguments = self.arguments.copy()
arguments[2] = "c" * 40
with mock.patch("sys.argv", arguments), mock.patch.object(ltfs_compare, "commit") as commit:
with self.assertRaisesRegex(GateError, "reviewed"):
ltfs_compare.main()
commit.assert_not_called()
def test_adapter_refuses_missing_or_ambiguous_anchors(self):
for replacement in ("", "type StreamCopyer struct {\ntype StreamCopyer struct {\n"):
with self.subTest(replacement=replacement):
source = self.root / str(len(replacement))
self.snapshot(None, self.baseline, source)
(source / "acp.go").write_text(replacement)
with self.assertRaisesRegex(GateError, "anchor changed"):
ltfs_compare.adapt(source, self.adapter)
def test_collection_uses_explicit_harness_and_scenario(self):
native = {"bench_test.go": b"package acp\n", "release_helper_test.go": b"package acp\n"}
ordinary = [profile + "/" + case + "/pipeline-4"
for profile in ("native47", "stress100") for case in ("small", "large", "mixed")]
ordinary.append("stress100/small-delayed-source/pipeline-4")
for scenario, names, selector in (
("ordinary", ordinary, ltfs_compare.SELECTOR),
("read-delay", ["stress100/medium-delayed-read/pipeline-4"],
"^BenchmarkLTFSPipeline$/^stress100$/^medium-delayed-read$/^pipeline$")):
with self.subTest(scenario=scenario):
output = self.root / scenario
output.mkdir()
arguments = [*self.arguments, "--scenario", scenario]
commands = []
def execute(command, **kwargs):
if command[:2] == ["git", "show"]:
return self.adapter.encode() if command[2].endswith(":filesystem.go") else b"package acp\n"
commands.append(command)
if "-bench=" + selector in command:
return ("\n".join("BenchmarkLTFSPipeline/" + name + " 1 100 ns/op" for name in names)
+ "\nPASS\n").encode()
return b"PASS\n"
with mock.patch("sys.argv", arguments), mock.patch.object(ltfs_compare, "commit") as commit, \
mock.patch.object(ltfs_compare, "private_output", return_value=output), \
mock.patch.object(ltfs_compare, "harness_files", return_value=native) as harness, \
mock.patch.object(ltfs_compare, "snapshot", side_effect=self.snapshot), \
mock.patch.object(ltfs_compare, "go_environment", side_effect=lambda *a, **k: ("go", {}, {})), \
mock.patch.object(ltfs_compare, "run", side_effect=execute), contextlib.redirect_stdout(io.StringIO()):
ltfs_compare.main()
self.assertEqual([call.args[1] for call in commit.call_args_list],
[self.baseline, self.candidate, self.harness])
self.assertEqual(harness.call_args.args[1], self.harness)
record = json.loads((output / "comparison.json").read_text())
self.assertTrue(record["complete"])
self.assertEqual(record["harness"], self.harness)
self.assertEqual(record["harness_files"], {name: digest(data) for name, data in native.items()})
self.assertEqual(len(record["runs"]), 2)
self.assertEqual(sum("-bench=" + selector in command for command in commands), 2)
patch = (output / (self.baseline[:12] + ".adapter.patch")).read_text()
self.assertIn("out.Cache(job.baseJob, c.toDevice.linear)", patch)
self.assertIn("o.copyer.refreshCacheEntry(o.output.File, o.target.name, job, true)", patch)
self.assertNotIn("refreshCachePath", patch)
+9 -8
View File
@@ -169,12 +169,12 @@ class RepositoryTest(RepositoryFixture, unittest.TestCase):
result = json.loads((directory / "comparison.json").read_text())
self.assertEqual(comparison, result)
self.assertEqual(result["flagged"], flagged)
self.assertEqual(len(result["metrics"]), 24)
self.assertEqual(len(result["metrics"]), len(release.BENCHMARKS) * len(release.METRICS))
row = result["metrics"][0]
self.assertEqual(row["baseline"], 100)
self.assertEqual(row["candidate"], changes.get("time", 100))
self.assertAlmostEqual(row["delta_percent"], changes.get("time", 100) - 100)
self.assertEqual(sum(row["flagged"] for row in result["metrics"]), 8 if flagged else 0)
self.assertEqual(sum(row["flagged"] for row in result["metrics"]), len(release.BENCHMARKS) if flagged else 0)
for row in result["metrics"]:
self.assertIn(row["benchmark"] + "\t" + row["metric"], stdout.getvalue())
@@ -220,7 +220,7 @@ class RepositoryTest(RepositoryFixture, unittest.TestCase):
directory, _ = self.evidence(benchmark_text(cpu=cpu, iterations=1234), cpu=cpu)
result = release.performance_check(self.repo, directory, self.base, self.candidate, self.base)
self.assertFalse(result["flagged"])
self.assertEqual(len(result["metrics"]), 24)
self.assertEqual(len(result["metrics"]), len(release.BENCHMARKS) * len(release.METRICS))
def test_documentation_and_checker_changes_retain_original_measurements_and_source_identity(self):
directory, record = self.evidence()
@@ -283,7 +283,7 @@ class RepositoryTest(RepositoryFixture, unittest.TestCase):
self.assertEqual((Path(args.out) / (side + ".bench")).read_bytes(), outputs[side])
result = json.loads((Path(args.out) / "comparison.json").read_text())
self.assertEqual(result["flagged"], time == 111)
self.assertEqual(len(result["metrics"]), 24)
self.assertEqual(len(result["metrics"]), len(release.BENCHMARKS) * len(release.METRICS))
def test_collector_keeps_incomplete_output_unaccepted(self):
args = SimpleNamespace(idle=True, cpu=4, baseline=self.base, candidate=self.candidate,
@@ -324,7 +324,7 @@ class RepositoryTest(RepositoryFixture, unittest.TestCase):
mock.patch.object(release.Path, "cwd", return_value=self.repo), mock.patch.object(release.os, "umask"), \
contextlib.redirect_stdout(io.StringIO()) as stdout, contextlib.redirect_stderr(io.StringIO()) as stderr:
self.assertEqual(release.main(), 0)
self.assertEqual(sum(line.startswith(release.BENCHMARKS) for line in stdout.getvalue().splitlines()), 24)
self.assertEqual(sum(line.startswith(release.BENCHMARKS) for line in stdout.getvalue().splitlines()), len(release.BENCHMARKS) * len(release.METRICS))
self.assertEqual(stderr.getvalue(), "")
if time == 111:
self.assertIn("investigate this benchmark", stdout.getvalue())
@@ -389,7 +389,7 @@ class RepositoryTest(RepositoryFixture, unittest.TestCase):
report = json.loads((output / "acceptance.json").read_text())
self.assertEqual(report["performance"], json.loads((directory / "comparison.json").read_text()))
self.assertEqual(report["performance"]["flagged"], time == 111)
self.assertEqual(len(report["performance"]["metrics"]), 24)
self.assertEqual(len(report["performance"]["metrics"]), len(release.BENCHMARKS) * len(release.METRICS))
self.assertFalse(report["publication_approved"])
final = stdout.getvalue().splitlines()[-1]
self.assertIn("publication approval is still required", final)
@@ -478,7 +478,7 @@ class PureGateTest(unittest.TestCase):
for cpu in (1, 4):
_, values = release.parse_benchmarks(benchmark_text(cpu=cpu), cpu)
self.assertEqual(set(values), set(release.BENCHMARKS))
self.assertEqual(sum(len(row) for row in values.values()), 24)
self.assertEqual(sum(len(row) for row in values.values()), len(release.BENCHMARKS) * len(release.METRICS))
self.assertEqual(values[release.BENCHMARKS[0]], {"ns/op": 100, "B/op": 100, "allocs/op": 10})
for text, cpu in ((benchmark_text(cpu=4), 1), (benchmark_text(cpu=1), 4),
(benchmark_text(cpu=2), 4), (benchmark_text().replace("-4\t", "-1\t"), 1)):
@@ -495,7 +495,8 @@ class PureGateTest(unittest.TestCase):
self.assertIn(b"<home>/file.go", log.read_bytes())
def test_benchmark_inventory_and_go_test_completion_are_required(self):
for text in (benchmark_text(benchmarks=release.BENCHMARKS[:-1]),
for text in (benchmark_text(benchmarks=release.BENCHMARKS[:8]),
benchmark_text(benchmarks=release.BENCHMARKS[:-1]),
benchmark_text(benchmarks=release.BENCHMARKS + (release.BENCHMARKS[0],)),
benchmark_text() * 2, benchmark_text().replace("PASS", "SKIP", 1),
benchmark_text().replace("PASS\n", ""), benchmark_text().split("ok \t")[0],
+48 -2
View File
@@ -31,8 +31,54 @@ type errorPair struct {
to error
}
// availableSpace is queried just before a linear target starts writing. Ordinary targets
// rely on real allocation and write errors instead of a second capacity estimate.
// A sample covers at most 64 single-output files of at most one copy chunk each.
// Below that logical headroom, each positive-size target gets a fresh observation.
const spaceSampleFiles = 64
type spaceEstimate struct {
device string
available int64
files int
}
func (c *StreamCopyer) checkLinearSpace(job *writeJob, target targetSpec) error {
// Only consecutive small single-output items can reuse the last mount's estimate.
size := job.stat.size
reuse := size > 0 && size <= batchSize && len(job.outputs) == 1
if !reuse {
c.linearSpace = spaceEstimate{}
}
if size <= 0 {
return nil
}
// Bound stale observations by both admitted file count and debited logical bytes.
sample := c.linearSpace
fresh := sample.files == 0 || sample.device != target.device ||
sample.files >= spaceSampleFiles || sample.available < spaceSampleFiles*batchSize
if fresh {
c.linearSpace = spaceEstimate{}
available, err := c.availableSpace(target.device)
if err != nil {
return err
}
sample = spaceEstimate{device: target.device, available: available}
}
if size > sample.available {
return fmt.Errorf("%w, want=%d have=%d", ErrTargetNoSpace, size, sample.available)
}
// Admission consumes estimated space even if the output later fails; never credit cleanup.
// This advisory logical estimate cannot account for allocation or metadata overhead.
if reuse {
sample.available -= size
sample.files++
c.linearSpace = sample
}
return nil
}
// availableSpace reads filesystem capacity; ordinary targets rely on real allocation errors.
func availableSpace(mountPoint string) (int64, error) {
usage, err := godf.NewDiskUsage(mountPoint)
if err != nil {
+4 -5
View File
@@ -361,7 +361,7 @@ func TestACPCommandStopsGracefullyWithACompleteReport(t *testing.T) {
// One file large enough that its copy is still in flight when the signal arrives, and
// many files behind it, so the stop has to account for queued work.
large := "0000-large.bin"
if err := os.WriteFile(filepath.Join(source, large), bytes.Repeat([]byte{'x'}, 16<<20), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(source, large), bytes.Repeat([]byte{'x'}, 64<<20), 0o644); err != nil {
t.Fatalf("write large source file: %v", err)
}
const small = 40
@@ -385,12 +385,11 @@ func TestACPCommandStopsGracefullyWithACompleteReport(t *testing.T) {
}
defer func() { _ = command.Process.Kill() }()
// The target file exists as soon as the copy stage owns the first item, so waiting for it
// proves the run has started and the signal lands while the copy is still in flight.
copied := filepath.Join(target, filepath.Base(source), large)
// A private staging file proves copying started before publication and completion.
copied := filepath.Join(target, filepath.Base(source), ".tmp_*")
deadline := time.Now().Add(30 * time.Second)
for {
if _, err := os.Stat(copied); err == nil {
if staged, _ := filepath.Glob(copied); len(staged) > 0 {
break
}
if time.Now().After(deadline) {
+91
View File
@@ -0,0 +1,91 @@
package acp
import (
"io"
"os"
"github.com/samuelncui/acp/internal/fileio"
)
var closeTarget = (*fileio.Output).Close
// transferFilesystem substitutes file operations without exposing transfer ownership to callers.
type transferFilesystem interface {
Open(path string, mode ReadMode, info os.FileInfo) (itemSource, error)
Create(job *writeJob, target targetSpec) (transferOutput, error)
}
// transferOutput retains the owned path after Close until Commit or Discard settles it.
type transferOutput interface {
io.WriteCloser
Sync() error
Cache(job *baseJob, linear bool)
Restore(*stat) error
Commit(overwrite bool) error
Discard() error
}
type nativeTransferFilesystem struct {
copyer *StreamCopyer
}
func (f nativeTransferFilesystem) Open(path string, mode ReadMode, info os.FileInfo) (itemSource, error) {
return openSourceContent(path, mode, info)
}
func (f nativeTransferFilesystem) Create(job *writeJob, target targetSpec) (transferOutput, error) {
// The existing allocator owns cleanup until it hands an output to this adapter.
output, err := f.copyer.prepareTarget(job, target)
if err != nil {
return nil, err
}
return &nativeTransferOutput{copyer: f.copyer, target: target, output: output}, nil
}
type nativeTransferOutput struct {
copyer *StreamCopyer
target targetSpec
output *fileio.Output
}
func (o *nativeTransferOutput) Write(data []byte) (int, error) {
return o.output.File.Write(data)
}
func (o *nativeTransferOutput) Close() error {
return closeTarget(o.output)
}
func (o *nativeTransferOutput) Sync() error {
return o.output.File.Sync()
}
func (o *nativeTransferOutput) Cache(job *baseJob, linear bool) {
// Linear completion has closed its descriptor but still owns the temporary path.
if linear {
o.copyer.refreshCachePath(o.output.Temporary, job, true)
return
}
// Random completion retains its descriptor until metadata and Sync finish.
o.copyer.refreshCacheEntry(o.output.File, o.target.name, job, true)
}
func (o *nativeTransferOutput) Restore(info *stat) error {
return restoreTarget(o.output.Temporary, info)
}
func (o *nativeTransferOutput) Commit(overwrite bool) error {
return commitTarget(o.output, overwrite)
}
func (o *nativeTransferOutput) Discard() error {
return o.output.Discard()
}
func (c *StreamCopyer) fs() transferFilesystem {
if c.filesystem != nil {
return c.filesystem
}
return nativeTransferFilesystem{copyer: c}
}
+55 -13
View File
@@ -48,24 +48,24 @@ func TestRunMapsDeviceFullToTargetNoSpace(t *testing.T) {
}
func TestDeviceFullWriteFailureDrainsQueuedBuffers(t *testing.T) {
// A refusing device fails the target while the producer still feeds the writer, which is the
// path that must release every queued read buffer.
// A refusing device fails despite an optimistic estimate while the producer still feeds buffers.
trackChunkPool(t)
root := t.TempDir()
target := filepath.Join(root, "target")
if err := os.Symlink("/dev/full", target); err != nil {
t.Fatal(err)
}
// A linear target writes without preallocation; only the actual write discovers ENOSPC.
size := int64(batchSize) * 4
readErr := errors.New("read failed")
item := newFixtureItem(filepath.Join(root, "source"), target)
// A linear target writes without pre-allocating, so the device's ENOSPC arrives while the
// producer still has buffers queued, which is the path under test.
copyer := newTestStream(t, Overwrite(true), SetToDevice(LinearDevice(true)))
// The push engine reports every outcome through the results callback, so the fixture is the
// observer of this stage-level run.
queries := 0
copyer.availableSpace = func(string) (int64, error) {
queries++
return size * 2, nil
}
fixture := newStreamFixture(item)
copyer.onResults = fixture.onResults
job := newWriteJob(&baseJob{
@@ -74,12 +74,38 @@ func TestDeviceFullWriteFailureDrainsQueuedBuffers(t *testing.T) {
path: filepath.Join(root, "source"),
stat: &stat{size: size, mode: 0o644},
targets: []string{target},
}, &failingContentReader{err: readErr, batches: 4}, false)
}, &failingContentReader{err: readErr, batches: 4})
// The writer must fail, release its queued buffers, and let the pipeline return.
// Queue later prepared items before starting the copy stage, avoiding scheduler-dependent prefetch.
jobs := []*writeJob{job}
var readers []*failingContentReader
for _, name := range []string{"later-1", "later-2"} {
later := newFixtureItem(filepath.Join(root, name+"-source"), filepath.Join(root, name))
fixture.add(later)
reader := &failingContentReader{err: readErr}
readers = append(readers, reader)
jobs = append(jobs, newWriteJob(&baseJob{
copyer: copyer, item: later, path: later.source,
stat: &stat{size: 1, mode: 0o644}, targets: later.targets,
}, reader))
}
prepared := make(chan *writeJob, len(jobs))
for _, job := range jobs {
spec, err := copyer.indexTarget(job.targets[0], nil)
if err != nil {
t.Fatal(err)
}
job.outputs = []targetSpec{spec}
prepared <- job
}
close(prepared)
// The writer must fail, release queued buffers, and report every accepted item exactly once.
done := make(chan error, 1)
go func() {
done <- runWriteJob(copyer, job)
copyed := copyer.copy(context.Background(), prepared)
runResults(copyer, copyed)
done <- copyer.Wait()
}()
select {
case err := <-done:
@@ -90,12 +116,28 @@ func TestDeviceFullWriteFailureDrainsQueuedBuffers(t *testing.T) {
t.Fatal("the pipeline deadlocked while draining a full target")
}
// The estimate is not a guarantee: retain ENOSPC and stop before any later reader or output starts.
result, terminalErr := item.terminal(t)
if terminalErr != nil {
t.Fatalf("an item whose targets failed must still complete: %v", terminalErr)
}
if len(result.Targets) != 1 || !errors.Is(result.Targets[0].Err, ErrTargetNoSpace) {
t.Fatalf("target outcome = %#v, want %v", result.Targets, ErrTargetNoSpace)
if len(result.Targets) != 1 || !errors.Is(result.Targets[0].Err, ErrTargetNoSpace) ||
!errors.Is(result.Targets[0].Err, syscall.ENOSPC) {
t.Fatalf("target outcome = %#v, want ENOSPC and %v", result.Targets, ErrTargetNoSpace)
}
if queries != 1 || !copyer.linearTargetStopped() {
t.Fatalf("capacity queries = %d; stopped = %t", queries, copyer.linearTargetStopped())
}
for i, later := range jobs[1:] {
if _, err := later.item.(*fixtureItem).terminal(t); !errors.Is(err, ErrTargetNoSpace) {
t.Errorf("prefetched item error = %v, want %v", err, ErrTargetNoSpace)
}
if later.reader != nil || !later.writeTime.IsZero() {
t.Errorf("prefetched item was started or retained its reader: reads=%d, job=%+v", readers[i].reads, later)
}
if _, err := os.Stat(later.targets[0]); !errors.Is(err, os.ErrNotExist) {
t.Errorf("prefetched target was created: %v", err)
}
}
}
@@ -183,7 +225,7 @@ func TestFullVolumePreallocationReportsNoSpace(t *testing.T) {
path: input,
stat: &stat{size: size, mode: 0o644},
targets: []string{target},
}, io.NopCloser(bytes.NewReader(make([]byte, size))), false)
}, io.NopCloser(bytes.NewReader(make([]byte, size))))
// A refused target is an item outcome, not a pipeline failure.
if err := runWriteJob(copyer, job); err != nil {
+19 -13
View File
@@ -329,28 +329,32 @@ func TestRunCopiesItemsToLinearTarget(t *testing.T) {
}
}
func TestForwardPreparedOrdersOnlyLinearTargets(t *testing.T) {
func TestForwardPreparedPreservesRequiredOrder(t *testing.T) {
// Deliver preparation completions in a different order from their source requests.
newJob := func(order uint64, path string) *writeJob {
return newWriteJob(
&baseJob{order: order, path: path},
io.NopCloser(strings.NewReader("fixture")),
false,
)
}
tests := []struct {
name string
linear bool
wantIDs []string
name string
fromLinear, toLinear bool
fromThreads, toThreads int
wantIDs []string
}{
{name: "linear request order", linear: true, wantIDs: []string{"1", "3"}},
{name: "random completion order", wantIDs: []string{"3", "1"}},
{name: "linear target", toLinear: true, fromThreads: 3, toThreads: 1, wantIDs: []string{"1", "3"}},
{name: "linear source", fromLinear: true, fromThreads: 1, toThreads: 3, wantIDs: []string{"1", "3"}},
{name: "serial random devices", fromThreads: 1, toThreads: 1, wantIDs: []string{"1", "3"}},
{name: "parallel source serial target", fromThreads: 3, toThreads: 1, wantIDs: []string{"3", "1"}},
{name: "serial source parallel target", fromThreads: 1, toThreads: 3, wantIDs: []string{"3", "1"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Deliver later preparation results first; a failed or skipped middle request has no write Job.
copyer := &StreamCopyer{option: &option{
fromDevice: &deviceOption{threads: 3},
toDevice: &deviceOption{linear: test.linear},
fromDevice: &deviceOption{threads: test.fromThreads, linear: test.fromLinear},
toDevice: &deviceOption{threads: test.toThreads, linear: test.toLinear},
}}
completed := make(chan prepareResult, 3)
completed <- prepareResult{order: 2, job: newJob(2, "3")}
@@ -359,7 +363,7 @@ func TestForwardPreparedOrdersOnlyLinearTargets(t *testing.T) {
close(completed)
prepared := make(chan *writeJob, 3)
// Forwarding must reorder only the serialized target path.
// Preserve former serial behavior while concurrent random preparation may complete out of order.
copyer.forwardPrepared(completed, prepared)
close(prepared)
var ids []string
@@ -1092,7 +1096,7 @@ func TestRunAppliesBoundedBackpressure(t *testing.T) {
input := writeSourceFile(t, root, "source.txt", nil)
const (
total = int64(256)
total = int64(2048)
readBuffer = 8
consumedMax = total / 2
)
@@ -1218,7 +1222,8 @@ func TestRunDoesNotReportAStopAfterACompleteRun(t *testing.T) {
}
func TestRunCancellationDrainsPrefetchedItems(t *testing.T) {
// Feed work until the pipeline is busy, so cancellation occurs with an active pipeline.
// Feed work until the pipeline holds prepared content, then cancel with buffers still queued.
trackChunkPool(t)
root := t.TempDir()
input := writeSourceFile(t, root, "source.txt", []byte("fixture"))
ctx, cancel := context.WithCancel(context.Background())
@@ -1229,7 +1234,8 @@ func TestRunCancellationDrainsPrefetchedItems(t *testing.T) {
produced []*fixtureItem
)
fixture := newStreamFixture()
stream, err := NewStream(ctx, fixture.onResults, SetToDevice(LinearDevice(true)), WithResultBuffer(1), WithResultBatch(1))
stream, err := NewStream(ctx, fixture.onResults, SetToDevice(LinearDevice(true)),
WithHashPolicy(HashReadRefresh), WithResultBuffer(1), WithResultBatch(1))
if err != nil {
t.Fatal(err)
}
+15 -38
View File
@@ -173,48 +173,32 @@ type writeJob struct {
*baseJob
reader io.ReadCloser
release func()
slotOnce sync.Once
// source is the descriptor this item owns: the reader that closes it, and the file the item
// reads its stored hash through and publishes its computed one through. It is nil for a job
// reads its stored hash through. Computed cache publication can follow Close by path. It is nil for a job
// that opened no source.
source *os.File
source *os.File
sourcePath string
consumed chan struct{}
sourceOnce sync.Once
sourceErr error
// hardStop ends the wait for a consumer when the pipeline fails fatally.
hardStop <-chan struct{}
// skipContent marks an item whose policy never reads the source content.
skipContent bool
}
func newWriteJob(job *baseJob, src io.ReadCloser, waitConsumed bool) *writeJob {
// The write stage adds resource ownership; all source facts remain with the indexed job.
j := &writeJob{
baseJob: job,
reader: src,
}
if job != nil && job.copyer != nil {
j.hardStop = job.copyer.hardStop
}
// Linear sources hand their next reader over only after this one is consumed.
if waitConsumed {
j.consumed = make(chan struct{})
}
return j
func newWriteJob(job *baseJob, src io.ReadCloser) *writeJob {
return &writeJob{baseJob: job, reader: src}
}
func (wj *writeJob) finishSource() error {
// Clear descriptor ownership before closing, and notify a linear producer exactly once.
// Clear descriptor ownership and close exactly once, independently of the data lane.
wj.sourceOnce.Do(func() {
reader := wj.reader
wj.reader = nil
wj.source = nil
if wj.consumed != nil {
defer close(wj.consumed)
}
if reader != nil {
wj.sourceErr = reader.Close()
}
@@ -222,19 +206,12 @@ func (wj *writeJob) finishSource() error {
return wj.sourceErr
}
// waitConsumed waits until the copy stage owns the reader. A graceful stop never releases the
// wait, because the consumer is what reports the item; only a fatal pipeline failure ends it.
func (wj *writeJob) waitConsumed() bool {
if wj.consumed == nil {
return true
}
select {
case <-wj.consumed:
return true
case <-wj.hardStop:
return false
}
func (wj *writeJob) releaseSlot() {
wj.slotOnce.Do(func() {
if wj.release != nil {
wj.release()
}
})
}
// Job is one report row in the af05f05c shape: Base is the directory the source-relative Path
+183
View File
@@ -0,0 +1,183 @@
package acp
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
"testing"
)
func TestLinearSpaceSampling(t *testing.T) {
// Exercise admission through real output creation; discarded outputs never refund estimates.
const mib = int64(1 << 20)
observationErr := errors.New("capacity observation failed")
type step struct {
size int64
targets int
mount string
available int64
queryErr error
wantCalls int
wantErr error
}
for _, tt := range []struct {
name string
steps []step
}{
{"logical debit and boundary refresh", []step{
{size: mib, available: 65 * mib, wantCalls: 1},
{size: mib, queryErr: observationErr, wantCalls: 1},
{size: mib, available: mib - 1, wantCalls: 2, wantErr: ErrTargetNoSpace},
{size: mib, available: mib, wantCalls: 3},
}},
{"mount change", []step{
{size: 1, mount: "first", available: 128 * mib, wantCalls: 1},
{size: 1, mount: "second", available: 128 * mib, wantCalls: 2},
{size: 1, mount: "first", available: 128 * mib, wantCalls: 3},
}},
{"large target invalidates", []step{
{size: 1, available: 128 * mib, wantCalls: 1},
{size: mib + 1, available: 128 * mib, wantCalls: 2},
{size: 1, available: 128 * mib, wantCalls: 3},
}},
{"multiple targets invalidate", []step{
{size: 1, available: 128 * mib, wantCalls: 1},
{size: 1, targets: 2, available: 128 * mib, wantCalls: 3},
{size: 1, available: 128 * mib, wantCalls: 4},
}},
{"empty target invalidates without querying", []step{
{size: 1, available: 128 * mib, wantCalls: 1},
{size: 0, queryErr: observationErr, wantCalls: 1},
{size: 1, available: 128 * mib, wantCalls: 2},
}},
{"observation error invalidates", []step{
{size: 1, available: 64 * mib, wantCalls: 1},
{size: 1, queryErr: observationErr, wantCalls: 2, wantErr: observationErr},
{size: 1, available: 128 * mib, wantCalls: 3},
}},
} {
t.Run(tt.name, func(t *testing.T) {
// Keep one linear writer and inject only the filesystem capacity observation.
root := t.TempDir()
opt := newOption()
opt.toDevice.linear = true
c := &StreamCopyer{option: opt}
calls := 0
for index, s := range tt.steps {
c.availableSpace = func(device string) (int64, error) {
calls++
if device != s.mount {
t.Fatalf("device = %q, want %q", device, s.mount)
}
return s.available, s.queryErr
}
targets := s.targets
if targets == 0 {
targets = 1
}
job := &writeJob{baseJob: &baseJob{stat: &stat{size: s.size}, outputs: make([]targetSpec, targets)}}
for target := range targets {
// A rejected admission must precede directory creation; accepted outputs are owned and discarded.
dir := filepath.Join(root, fmt.Sprintf("%d-%d", index, target))
out, err := c.prepareTarget(job, targetSpec{path: filepath.Join(dir, "target"), device: s.mount})
if !errors.Is(err, s.wantErr) {
t.Fatalf("step %d: error = %v, want %v", index, err, s.wantErr)
}
if out != nil {
if err := out.Discard(); err != nil {
t.Fatal(err)
}
} else if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("rejected step %d created output directory: %v", index, err)
}
}
if calls != s.wantCalls {
t.Fatalf("step %d: capacity calls = %d, want %d", index, calls, s.wantCalls)
}
}
})
}
}
func TestLinearSpaceSamplingRefreshesAfterBoundedFiles(t *testing.T) {
// Even tiny files must refresh the observation after a bounded number of admissions.
root := t.TempDir()
opt := newOption()
opt.toDevice.linear = true
calls := 0
c := &StreamCopyer{option: opt, availableSpace: func(string) (int64, error) { calls++; return 1 << 30, nil }}
job := &writeJob{baseJob: &baseJob{stat: &stat{size: 1}, outputs: make([]targetSpec, 1)}}
for index := range 65 {
// Real output cleanup does not erase admitted file accounting.
out, err := c.prepareTarget(job, targetSpec{device: root, path: filepath.Join(root, fmt.Sprint(index))})
if err != nil {
t.Fatal(err)
}
if err := out.Discard(); err != nil {
t.Fatal(err)
}
want := 1
if index == 64 {
want = 2
}
if calls != want {
t.Fatalf("after %d files: capacity calls = %d, want %d", index+1, calls, want)
}
}
}
func TestLinearSpaceSamplingRetainsActualWriteFailure(t *testing.T) {
// Populate a shared-mount estimate with a successful small output before a refusing device.
if _, err := os.Stat("/dev/full"); err != nil {
t.Skip("requires /dev/full")
}
root := t.TempDir()
content := bytes.Repeat([]byte("x"), 256<<10)
source := writeSourceFile(t, root, "source", content)
good := newFixtureItem(source, filepath.Join(root, "good"))
bad := newFixtureItem(source, "/dev/full")
later := newFixtureItem(source, filepath.Join(root, "later"))
fixture := newStreamFixture(good, bad, later)
c, err := NewStream(context.Background(), fixture.onResults, Overwrite(true), SetToDevice(LinearDevice(true)))
if err != nil {
t.Fatal(err)
}
calls := 0
c.getDevice = func(string) (string, error) { return root, nil }
c.availableSpace = func(string) (int64, error) { calls++; return 1 << 30, nil }
// The real second Write must stop the linear stream even though its estimate was reused.
if err := c.Submit(good, bad, later); err != nil {
t.Fatal(err)
}
if err := c.Close(); err != nil {
t.Fatal(err)
}
if err := c.Wait(); err != nil {
t.Fatal(err)
}
result, err := good.terminal(t)
if err != nil || len(result.Targets) != 1 || result.Targets[0].Err != nil {
t.Fatalf("first output: %+v / %v", result, err)
}
if data, err := os.ReadFile(good.targets[0]); err != nil || !bytes.Equal(data, content) {
t.Fatalf("first content: %v", err)
}
result, err = bad.terminal(t)
if err != nil || len(result.Targets) != 1 || !errors.Is(result.Targets[0].Err, syscall.ENOSPC) || !errors.Is(result.Targets[0].Err, ErrTargetNoSpace) {
t.Fatalf("refusing output: %+v / %v", result, err)
}
if _, err := later.terminal(t); !errors.Is(err, ErrTargetNoSpace) {
t.Fatalf("later item: %v", err)
}
if calls != 1 || !c.linearTargetStopped() {
t.Fatalf("queries=%d stopped=%t", calls, c.linearTargetStopped())
}
if _, err := os.Stat(later.targets[0]); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("later output started: %v", err)
}
}
+273
View File
@@ -0,0 +1,273 @@
package acp
import (
"context"
"crypto/sha256"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"testing/synctest"
"time"
)
func BenchmarkLTFSPipeline(b *testing.B) {
// Each operation copies and physically finalizes 2 GiB; fixture creation is outside timing.
const total = 2 << 30
for _, stress := range []bool{false, true} {
profile := "native47"
if stress {
profile = "stress100"
}
distributions := []string{"small", "large", "mixed"}
if stress {
distributions = append(distributions, "small-delayed-source", "medium-delayed-read")
}
for _, distribution := range distributions {
for _, barrier := range []bool{true, false} {
mode := "pipeline"
if barrier {
mode = "file-barrier-reference"
}
b.Run(profile+"/"+distribution+"/"+mode, func(b *testing.B) {
// Reuse real source bytes; destinations cross the same filesystem seam as production.
root := b.TempDir()
sizes := []int{256 << 10}
if distribution == "large" {
sizes = []int{16 << 20}
}
if distribution == "mixed" {
sizes = []int{256 << 10, 16 << 20}
}
if distribution == "medium-delayed-read" {
sizes = []int{1 << 20}
}
paths := make(map[int]string)
hashes := make(map[int][32]byte)
for _, size := range sizes {
content := make([]byte, size)
state := uint64(0x987654321)
for i := range content {
state ^= state << 13
state ^= state >> 7
state ^= state << 17
content[i] = byte(state)
}
path := filepath.Join(root, fmt.Sprintf("source-%d", size))
if err := os.WriteFile(path, content, 0600); err != nil {
b.Fatal(err)
}
paths[size], hashes[size] = path, sha256.Sum256(content)
}
var items []Item
for remaining, index := total, 0; remaining > 0; index++ {
size := sizes[index%len(sizes)]
if size > remaining {
size = sizes[0]
}
items = append(items, &SimpleJob{Path: paths[size], Dsts: []string{filepath.Join(root, fmt.Sprintf("target-%d", index))}})
remaining -= size
}
b.ReportAllocs()
b.SetBytes(total)
b.ResetTimer()
// The barrier control intentionally waits for each file result; it is not a historical ACP build.
var backhitches int
var hostPeak, drivePeak int64
var busy, overshoot int64
for iteration := 0; iteration < b.N; iteration++ {
config := tapeProfile(stress)
// Keep the full configured 1 GiB LTFS pool and nominal 256 MB drive buffer.
m := newLTFSModel(config)
settled := make(chan struct{}, 1)
seen := 0
var resultErr error
opts := []Option{WithHashPolicy(HashReadRefresh), WithResultBatch(1), SetToDevice(LinearDevice(true))}
if distribution == "medium-delayed-read" {
opts = append(opts, SetFromDevice(DeviceThreads(4)))
}
c, err := NewStream(context.Background(), func(results []Result) error {
for _, r := range results {
seen++
want := hashes[int(r.Size)]
if r.Err != nil || len(r.Targets) != 1 || r.Targets[0].Err != nil || string(r.SHA256) != string(want[:]) {
resultErr = fmt.Errorf("invalid benchmark result: %+v", r)
}
if barrier {
settled <- struct{}{}
}
}
return nil
}, opts...)
if err != nil {
b.Fatal(err)
}
sourceFS := c.fs()
if distribution == "small-delayed-source" {
sourceFS = delayedSourceFilesystem{transferFilesystem: sourceFS, delay: 4 * time.Millisecond}
}
if distribution == "medium-delayed-read" {
sourceFS = delayedSourceFilesystem{transferFilesystem: sourceFS, readDelay: 20 * time.Millisecond}
}
c.filesystem = ltfsFilesystem{transferFilesystem: sourceFS, model: m}
for _, item := range items {
if err := c.Submit(item); err != nil {
b.Fatal(err)
}
if barrier {
<-settled
}
}
if err := c.Wait(); err != nil {
b.Fatal(err)
}
if err := m.validate(); err != nil {
b.Fatal(err)
}
m.shutdown()
b.StopTimer()
if resultErr != nil || seen != len(items) || m.completed != total || m.peakWrite != 1 {
b.Fatalf("invalid measured operation: %v results=%d bytes=%d writers=%d", resultErr, seen, m.completed, m.peakWrite)
}
backhitches += m.backhitches
hostPeak = max(hostPeak, m.peak)
drivePeak = max(drivePeak, m.drivePeak)
busy += int64(m.busy)
overshoot = max(overshoot, int64(m.overshoot))
if directory := os.Getenv("ACP_MOCK_REPORT_DIR"); directory != "" && iteration == 0 {
writeTapeModelReport(b, directory, m)
}
b.StartTimer()
}
b.StopTimer()
b.ReportMetric(float64(backhitches)/float64(b.N), "backhitches/op")
b.ReportMetric(float64(hostPeak)/(1<<20), "host-peak-MiB")
b.ReportMetric(float64(drivePeak)/(1<<20), "drive-peak-MiB")
b.ReportMetric(float64(busy)/float64(b.N)/1e9, "physical-busy-s/op")
b.ReportMetric(float64(overshoot)/1e6, "timer-overshoot-ms")
})
}
}
}
}
func writeTapeModelReport(b testing.TB, directory string, m *ltfsModel) {
b.Helper()
// Curves retain startup and final drain; every sample conserves host, drive and physical bytes.
if err := os.MkdirAll(directory, 0755); err != nil {
b.Fatal(err)
}
name := strings.ReplaceAll(b.Name(), "/", "_")
file, err := os.Create(filepath.Join(directory, name+".csv"))
if err != nil {
b.Fatal(err)
}
writer := csv.NewWriter(file)
_ = writer.Write([]string{"elapsed_seconds", "physical_bytes", "tape_position", "host_bytes", "drive_bytes"})
for _, sample := range m.curve {
_ = writer.Write([]string{strconv.FormatFloat(sample.elapsed.Seconds(), 'f', 9, 64), strconv.FormatInt(sample.bytes, 10), strconv.FormatInt(sample.position, 10), strconv.FormatInt(sample.host, 10), strconv.FormatInt(sample.drive, 10)})
}
writer.Flush()
if err := writer.Error(); err != nil {
b.Fatal(err)
}
if err := file.Close(); err != nil {
b.Fatal(err)
}
// Record the controller inputs and decisions independently of physical completion samples.
controlFile, err := os.Create(filepath.Join(directory, name+".control.csv"))
if err != nil {
b.Fatal(err)
}
controlWriter := csv.NewWriter(controlFile)
_ = controlWriter.Write([]string{"elapsed_seconds", "drive_bytes", "estimated_input_Bps", "target_Bps", "actual_Bps"})
for _, s := range m.controls {
_ = controlWriter.Write([]string{strconv.FormatFloat(s.elapsed.Seconds(), 'f', 9, 64), strconv.FormatInt(s.buffer, 10), strconv.FormatFloat(s.feed, 'f', 3, 64), strconv.FormatFloat(s.target, 'f', 3, 64), strconv.FormatFloat(s.rate, 'f', 3, 64)})
}
controlWriter.Flush()
if err := controlWriter.Error(); err != nil {
b.Fatal(err)
}
if err := controlFile.Close(); err != nil {
b.Fatal(err)
}
// Scenario parameters distinguish vendor facts and declared control/motion assumptions.
info := map[string]any{"scenario": b.Name(), "bytes": m.completed, "minimum_bytes_per_second": m.config.minimumRate, "maximum_bytes_per_second": m.config.bytesPerSecond, "host_buffer_bytes": m.config.bufferBytes, "drive_buffer_bytes": m.config.driveBufferBytes, "restart_bytes": m.config.resumeBytes, "backhitch_seconds": m.config.backhitch.Seconds(), "initial_stream_bytes_per_second": m.config.streamRate, "adaptive": m.config.adaptive, "control_period_seconds": m.config.controlPeriod.Seconds(), "smoothing_seconds": m.config.smoothing.Seconds(), "watermark_fraction": m.config.targetFill, "buffer_correction_seconds": m.config.correction.Seconds(), "rate_slew_bytes_per_second_squared": m.config.slewPerSecond, "backhitches": m.backhitches, "seeks": m.seeks, "physical_busy_seconds": m.busy.Seconds(), "max_timer_overshoot_seconds": m.overshoot.Seconds(), "hash_policy": "HashReadRefresh", "source_fixture": "One reused native source per size; warm cache I/O, real content re-read and hashed", "note": "Single FUSE dispatcher; independent physical drain with adaptive control; full 256 MB drive buffer; motion and hysteresis are assumptions. File-barrier reference is a serialized control, not baseline ACP."}
if strings.Contains(b.Name(), "small-delayed-source") {
info["synthetic_source_open_seconds"] = 0.004
}
if strings.Contains(b.Name(), "medium-delayed-read") {
info["synthetic_first_read_seconds"] = 0.020
info["source_threads"] = 4
info["source_file_bytes"] = 1 << 20
}
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
b.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, name+".json"), append(data, '\n'), 0644); err != nil {
b.Fatal(err)
}
}
// delayedSourceFilesystem isolates synthetic source latency from physical tape service.
type delayedSourceFilesystem struct {
transferFilesystem
delay, readDelay time.Duration
}
func (f delayedSourceFilesystem) Open(path string, mode ReadMode, info os.FileInfo) (itemSource, error) {
// Open latency remains distinct from the first content read's latency.
time.Sleep(f.delay)
source, err := f.transferFilesystem.Open(path, mode, info)
if err != nil {
return source, err
}
if f.readDelay > 0 {
source.reader = &firstReadDelayReader{ReadCloser: source.reader, delay: f.readDelay}
}
return source, nil
}
// firstReadDelayReader delays one real content Read, never EOF probes or later chunks.
type firstReadDelayReader struct {
io.ReadCloser
delay time.Duration
}
func (r *firstReadDelayReader) Read(p []byte) (int, error) {
if len(p) > 0 && r.delay > 0 {
time.Sleep(r.delay)
r.delay = 0
}
return r.ReadCloser.Read(p)
}
func TestFirstReadDelayPreservesContentAndDelaysOnce(t *testing.T) {
// The sensitivity delays content acquisition, without charging empty probes or EOF again.
synctest.Test(t, func(t *testing.T) {
reader := &firstReadDelayReader{ReadCloser: io.NopCloser(strings.NewReader("payload")), delay: 20 * time.Millisecond}
defer reader.Close()
start := time.Now()
if n, err := reader.Read(nil); n != 0 || err != nil || time.Since(start) != 0 {
t.Fatalf("empty probe changed: n=%d err=%v elapsed=%s", n, err, time.Since(start))
}
// Real source bytes and EOF semantics survive the single first-content latency.
data, err := io.ReadAll(reader)
if err != nil || string(data) != "payload" || time.Since(start) != 20*time.Millisecond {
t.Fatalf("delayed content changed: data=%q err=%v elapsed=%s", data, err, time.Since(start))
}
if n, err := reader.Read(make([]byte, 1)); n != 0 || err != io.EOF || time.Since(start) != 20*time.Millisecond {
t.Fatalf("EOF was delayed: n=%d err=%v elapsed=%s", n, err, time.Since(start))
}
})
}
+451
View File
@@ -0,0 +1,451 @@
package acp
import (
"errors"
"fmt"
"io"
"os"
"sync"
"time"
)
// ltfsModel separates the FUSE dispatcher, finite host cache and one physical tape server.
// Delays other than the measured dispatcher mode are scenario inputs, not hardware predictions.
type ltfsModelConfig struct {
adaptive bool
controlPeriod, smoothing, correction time.Duration
targetFill float64
slewPerSecond int64
driveBufferBytes, minimumRate, resumeBytes int64
streamRate int64
seekCost func(from, to int64) time.Duration
dispatchers int // One matches the profiled LTFS -s mount; zero allows concurrent FUSE requests.
bufferBytes int64
bytesPerSecond int64
capacity int64 // Zero means the scenario does not inject EOM.
startup, seek, backhitch time.Duration
capture bool
before, entered func(operation, path string)
service func(tapeRequest) error // Deterministic physical gate/error injection for unit tests.
closeError func(path string) error
}
type tapeRequest struct {
queued time.Time
accepted chan struct{}
driverErr error
path string
read bool
position, size int64
done chan struct{}
err error
}
type tapeSample struct {
host, drive int64
elapsed time.Duration
bytes, position, pending int64
}
type tapeExtent struct {
path string
position, size int64
}
type ltfsModel struct {
driverAccepted int64
matching speedMatching
controls []speedSample
driveRequests []*tapeRequest
backendDone bool
enqueueLock sync.Mutex
driveDone chan struct{}
drivePending, drivePeak, failedBytes int64
finalizing bool
state string
motions []tapeMotion
config ltfsModelConfig
dispatch chan struct{}
queue chan *tapeRequest
done chan struct{}
lock sync.Mutex
changed chan struct{}
pending, peak, accepted, completed, position, appendPosition int64
busy, overshoot time.Duration
seeks, backhitches, activeClose, peakClose, activeWrite, peakWrite, activeRead, peakRead int
last *tapeRequest
failure error
outputs map[string]*ltfsOutput
extents []tapeExtent
curve []tapeSample
started time.Time
}
type tapeMotion struct {
elapsed time.Duration
state string
rate int64
}
func newLTFSModel(config ltfsModelConfig) *ltfsModel {
if config.bufferBytes < 0 || config.driveBufferBytes < 0 || config.minimumRate < 0 || config.minimumRate > config.bytesPerSecond {
panic("invalid tape-model byte limits or speeds")
}
// The host cache is independent of ACP's shared chunk credits.
if config.bufferBytes == 0 {
config.bufferBytes = 256 << 20
}
if config.driveBufferBytes == 0 {
config.driveBufferBytes = 256_000_000
}
if config.minimumRate > 0 && config.resumeBytes == 0 {
config.resumeBytes = config.driveBufferBytes * 6 / 10
}
if config.adaptive {
if config.controlPeriod == 0 {
config.controlPeriod = 100 * time.Millisecond
}
if config.smoothing == 0 {
config.smoothing = time.Second
}
if config.correction == 0 {
config.correction = 2 * time.Second
}
if config.targetFill == 0 {
config.targetFill = 0.6
}
if config.slewPerSecond == 0 {
config.slewPerSecond = 20_000_000
}
}
if config.streamRate == 0 {
config.streamRate = config.minimumRate
if config.streamRate == 0 {
config.streamRate = config.bytesPerSecond
}
}
if config.streamRate < config.minimumRate || config.streamRate > config.bytesPerSecond {
panic("invalid fixed tape speed")
}
if config.resumeBytes > config.driveBufferBytes {
panic("unreachable drive restart watermark")
}
m := &ltfsModel{driveDone: make(chan struct{}), config: config, queue: make(chan *tapeRequest, 1024), done: make(chan struct{}), changed: make(chan struct{}), outputs: make(map[string]*ltfsOutput), started: time.Now()}
if config.dispatchers > 0 {
m.dispatch = make(chan struct{}, config.dispatchers)
}
m.matching = speedMatching{last: m.started, rate: float64(config.streamRate)}
go m.serveDrive()
go m.serve()
return m
}
func (m *ltfsModel) request(operation, path string) func() {
// Observe application entry before FUSE scheduling, so queued calls remain distinguishable.
m.lock.Lock()
switch operation {
case "write":
m.activeWrite++
m.peakWrite = max(m.peakWrite, m.activeWrite)
case "read":
m.activeRead++
m.peakRead = max(m.peakRead, m.activeRead)
case "close":
m.activeClose++
m.peakClose = max(m.peakClose, m.activeClose)
}
m.lock.Unlock()
if m.config.before != nil {
m.config.before(operation, path)
}
if m.dispatch != nil {
m.dispatch <- struct{}{}
}
if m.config.entered != nil {
m.config.entered(operation, path)
}
// Physical draining never needs the FUSE permit held by a waiting Flush.
return func() {
if m.dispatch != nil {
<-m.dispatch
}
m.lock.Lock()
defer m.lock.Unlock()
switch operation {
case "write":
m.activeWrite--
case "read":
m.activeRead--
case "close":
m.activeClose--
}
}
}
func (m *ltfsModel) enqueue(path string, read bool, position, size int64) (*tapeRequest, error) {
// Serialize host acceptance separately from the ledger lock so queue backpressure cannot deadlock the server.
m.enqueueLock.Lock()
defer m.enqueueLock.Unlock()
// Writes reserve finite LTFS cache space until backend WRITE accepts them into the drive.
for {
m.lock.Lock()
if m.failure != nil {
err := m.failure
m.lock.Unlock()
return nil, err
}
if read || m.pending+size <= m.config.bufferBytes {
break
}
changed := m.changed
m.lock.Unlock()
<-changed
}
if !read {
position = m.appendPosition
m.appendPosition += size
m.pending += size
m.peak = max(m.peak, m.pending)
}
r := &tapeRequest{queued: time.Now(), path: path, read: read, position: position, size: size, done: make(chan struct{}), accepted: make(chan struct{})}
m.accepted += size
m.last = r
// Queue handoff keeps request order without holding the lock needed to release cache space.
m.lock.Unlock()
m.queue <- r
return r, nil
}
func (m *ltfsModel) serve() {
// LTFS backend commands are ordered, but acceptance into a drive buffer is not tape durability.
defer close(m.done)
defer func() { m.lock.Lock(); m.backendDone = true; m.signal(); m.lock.Unlock() }()
for r := range m.queue {
m.lock.Lock()
for !r.read && m.drivePending+r.size > m.config.driveBufferBytes && m.failure == nil {
changed := m.changed
m.lock.Unlock()
<-changed
m.lock.Lock()
}
if !r.read {
m.pending -= r.size
}
r.driverErr = m.failure
if r.driverErr == nil && !r.read {
m.drivePending += r.size
m.driverAccepted += r.size
m.drivePeak = max(m.drivePeak, m.drivePending)
}
m.signal()
m.lock.Unlock()
// Failed command tickets still release both waiters and conservation accounting.
if r.driverErr != nil {
m.lock.Lock()
m.failedBytes += r.size
m.signal()
r.err = r.driverErr
close(r.done)
m.lock.Unlock()
close(r.accepted)
continue
}
m.lock.Lock()
m.driveRequests = append(m.driveRequests, r)
m.signal()
m.lock.Unlock()
close(r.accepted)
}
}
func (m *ltfsModel) signal() {
close(m.changed)
m.changed = make(chan struct{})
}
func (m *ltfsModel) finalize() error {
// Join all accepted physical work, including older tickets before a later rejected command.
m.lock.Lock()
defer m.lock.Unlock()
m.finalizing = true
m.signal()
for m.completed+m.failedBytes < m.accepted || m.state == "backhitch" {
changed := m.changed
m.lock.Unlock()
<-changed
m.lock.Lock()
}
return m.failure
}
func (m *ltfsModel) shutdown() { _ = m.finalize(); close(m.queue); <-m.done; <-m.driveDone }
type ltfsFilesystem struct {
transferFilesystem
model *ltfsModel
readPositions map[string]int64
}
func (f ltfsFilesystem) Open(path string, mode ReadMode, info os.FileInfo) (itemSource, error) {
// Source bytes stay real; the adapter adds the modeled tape positioning and service boundary.
source, err := f.transferFilesystem.Open(path, mode, info)
if err != nil {
return source, err
}
if position, ok := f.readPositions[path]; ok {
source.reader = &ltfsReader{ReadCloser: source.reader, model: f.model, path: path, position: position}
}
return source, nil
}
func (f ltfsFilesystem) Create(_ *writeJob, target targetSpec) (transferOutput, error) {
// Temp ownership is represented by this output; publication is distinct from data completion.
release := f.model.request("create", target.path)
defer release()
out := &ltfsOutput{model: f.model, path: target.path}
f.model.lock.Lock()
f.model.outputs[target.path] = out
f.model.lock.Unlock()
return out, nil
}
type ltfsReader struct {
io.ReadCloser
model *ltfsModel
path string
position int64
}
func (r *ltfsReader) Read(data []byte) (int, error) {
// Read service shares the physical server with every other file and direction.
release := r.model.request("read", r.path)
defer release()
n, err := r.ReadCloser.Read(data)
if n == 0 {
return n, err
}
ticket, readErr := r.model.enqueue(r.path, true, r.position, int64(n))
if readErr != nil {
return 0, readErr
}
<-ticket.done
if ticket.err != nil {
return 0, ticket.err
}
r.position += int64(n)
return n, err
}
func (r *ltfsReader) Close() error {
release := r.model.request("source-close", r.path)
defer release()
return r.ReadCloser.Close()
}
type ltfsOutput struct {
model *ltfsModel
path string
last *tapeRequest
closed, committed, discarded bool
closeErr error
data []byte
bytes int64
}
func (o *ltfsOutput) Write(data []byte) (int, error) {
// Calls may overlap across files, but accepted data always enters one append-only tape queue.
release := o.model.request("write", o.path)
defer release()
if o.closed {
return 0, os.ErrClosed
}
n := 0
for n < len(data) {
size := min(int64(len(data)-n), o.model.config.bufferBytes, o.model.config.driveBufferBytes, int64(1<<20))
ticket, err := o.model.enqueue(o.path, false, 0, size)
if err != nil {
return n, err
}
o.last = ticket
n += int(size)
}
if o.model.config.capture {
o.data = append(o.data, data...)
}
o.bytes += int64(n)
return n, nil
}
func (o *ltfsOutput) flush() error {
if o.last == nil {
return nil
}
<-o.last.accepted
return o.last.driverErr
}
func (o *ltfsOutput) Close() error {
// Close joins backend acceptance into the drive buffer; Finalize separately joins physical media.
if o.closed {
return o.closeErr
}
release := o.model.request("close", o.path)
defer release()
o.closed = true
o.closeErr = o.flush()
if o.model.config.closeError != nil {
o.closeErr = errors.Join(o.closeErr, o.model.config.closeError(o.path))
}
return o.closeErr
}
func (o *ltfsOutput) Sync() error {
release := o.model.request("sync", o.path)
defer release()
return o.flush()
}
func (o *ltfsOutput) Cache(_ *baseJob, _ bool) {
release := o.model.request("cache", o.path)
defer release()
}
func (o *ltfsOutput) Restore(_ *stat) error {
release := o.model.request("metadata", o.path)
defer release()
return nil
}
func (o *ltfsOutput) Commit(_ bool) error {
// A failed Close cannot become a published file, even if cleanup calls Close again.
if err := o.Close(); err != nil {
return err
}
release := o.model.request("rename", o.path)
defer release()
o.committed = true
return nil
}
func (o *ltfsOutput) Discard() error {
err := o.Close()
if !o.committed {
o.discarded = true
}
return err
}
func (m *ltfsModel) validate() error {
// The mock's own ledger must settle before its measurements can support a pipeline comparison.
if err := m.finalize(); err != nil {
return err
}
m.lock.Lock()
defer m.lock.Unlock()
if m.pending != 0 || m.drivePending != 0 || m.accepted != m.completed || m.peak > m.config.bufferBytes || m.drivePeak > m.config.driveBufferBytes {
return fmt.Errorf("unsettled tape ledger: accepted=%d completed=%d pending=%d peak=%d", m.accepted, m.completed, m.pending, m.peak)
}
return nil
}
+429
View File
@@ -0,0 +1,429 @@
package acp
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"path/filepath"
"sync"
"sync/atomic"
"syscall"
"testing"
"testing/synctest"
"time"
)
func awaitStage(t *testing.T, ch <-chan struct{}, stage string) {
t.Helper()
select {
case <-ch:
case <-time.After(10 * time.Second):
t.Fatalf("timed out waiting for %s", stage)
}
}
func onceRelease(ch chan struct{}) func() {
var once sync.Once
return func() { once.Do(func() { close(ch) }) }
}
func TestPipelineOverlapsHashWriteAndLinearClose(t *testing.T) {
// Hold hashing and the first Close independently while both ordered data writes progress.
root := t.TempDir()
content := bytes.Repeat([]byte("parallel content"), 64<<10)
source := writeSourceFile(t, root, "source", content)
a, b := filepath.Join(root, "a"), filepath.Join(root, "b")
hashStarted, _, releaseHash := holdContentHash(t)
t.Cleanup(releaseHash)
closeStarted, secondWrite, closeGate := make(chan struct{}), make(chan struct{}), make(chan struct{})
releaseClose := onceRelease(closeGate)
t.Cleanup(releaseClose)
var closeOnce, writeOnce sync.Once
model := newLTFSModel(ltfsModelConfig{capture: true, entered: func(op, path string) {
if op == "close" && path == a {
closeOnce.Do(func() { close(closeStarted) })
<-closeGate
}
if op == "write" && path == b {
writeOnce.Do(func() { close(secondWrite) })
}
}})
defer model.shutdown()
results := make(chan Result, 2)
c, err := NewStream(context.Background(), func(batch []Result) error {
for _, r := range batch {
results <- r
}
return nil
}, WithHashPolicy(HashRead), WithResultBatch(1), SetToDevice(LinearDevice(true)))
if err != nil {
t.Fatal(err)
}
c.filesystem = ltfsFilesystem{transferFilesystem: c.fs(), model: model}
if err := c.Submit(&SimpleJob{Path: source, Dsts: []string{a}}, &SimpleJob{Path: source, Dsts: []string{b}}); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() { _ = c.Close(); close(done) }()
// A's Close and B's Write both start while A's digest remains unavailable.
awaitStage(t, hashStarted, "hash consumer")
awaitStage(t, closeStarted, "first Close before hash completion")
awaitStage(t, secondWrite, "second Write before first Close completion")
select {
case r := <-results:
t.Fatalf("premature result: %+v", r)
default:
}
releaseClose()
releaseHash()
awaitStage(t, done, "pipeline drain")
if err := c.Wait(); err != nil {
t.Fatal(err)
}
if err := model.validate(); err != nil {
t.Fatal(err)
}
// The same bytes reach both destinations once, while linear application Write never overlaps.
want := sha256.Sum256(content)
for range 2 {
r := <-results
if r.Err != nil || r.Targets[0].Err != nil || !bytes.Equal(r.SHA256, want[:]) {
t.Fatalf("result: %+v", r)
}
}
for _, path := range []string{a, b} {
out := model.outputs[path]
if !out.committed || !bytes.Equal(out.data, content) {
t.Fatalf("output %q is incomplete", path)
}
}
if model.peakWrite != 1 || model.accepted != int64(2*len(content)) {
t.Fatalf("write concurrency=%d accepted=%d", model.peakWrite, model.accepted)
}
}
func TestMockSingleFUSEDispatchQueuesWriteBehindClose(t *testing.T) {
// A waiting Close holds the calibrated single FUSE dispatcher, independently of application calls.
synctest.Test(t, func(t *testing.T) {
closeGate, closeEntered, writeCalled, writeEntered := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{})
m := newLTFSModel(ltfsModelConfig{dispatchers: 1, before: func(op, path string) {
if op == "write" {
close(writeCalled)
}
}, entered: func(op, path string) {
if op == "close" {
close(closeEntered)
<-closeGate
}
if op == "write" {
close(writeEntered)
}
}})
a, b := &ltfsOutput{model: m, path: "a"}, &ltfsOutput{model: m, path: "b"}
go func() { _ = a.Close() }()
<-closeEntered
go func() { _, _ = b.Write([]byte("x")) }()
<-writeCalled
synctest.Wait()
select {
case <-writeEntered:
t.Fatal("Write executed inside a blocked single-dispatch Close")
default:
}
close(closeGate)
<-writeEntered
synctest.Wait()
if err := m.validate(); err != nil {
t.Fatal(err)
}
m.shutdown()
})
}
func TestPipelineLinearReadAdvancesBeforeSourceClose(t *testing.T) {
// Tape source reads retain their order, while the prior descriptor closes independently.
root := t.TempDir()
a := writeSourceFile(t, root, "a", []byte("aaaa"))
b := writeSourceFile(t, root, "b", []byte("bbbb"))
gate, closing, reading := make(chan struct{}), make(chan struct{}), make(chan struct{})
release := onceRelease(gate)
t.Cleanup(release)
var closeOnce, readOnce sync.Once
m := newLTFSModel(ltfsModelConfig{entered: func(op, path string) {
if op == "source-close" && path == a {
closeOnce.Do(func() { close(closing) })
<-gate
}
if op == "read" && path == b {
readOnce.Do(func() { close(reading) })
}
}})
defer m.shutdown()
var seen atomic.Int64
c, err := NewStream(context.Background(), func(r []Result) error { seen.Add(int64(len(r))); return nil }, WithHashPolicy(HashRead), SetFromDevice(LinearDevice(true)))
if err != nil {
t.Fatal(err)
}
c.filesystem = ltfsFilesystem{transferFilesystem: c.fs(), model: m, readPositions: map[string]int64{a: 0, b: 4}}
if err := c.Submit(&SimpleJob{Path: a}, &SimpleJob{Path: b}); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() { _ = c.Close(); close(done) }()
awaitStage(t, closing, "first source Close")
awaitStage(t, reading, "second ordered Read")
release()
awaitStage(t, done, "source drain")
if err := c.Wait(); err != nil {
t.Fatal(err)
}
if err := m.validate(); err != nil {
t.Fatal(err)
}
if seen.Load() != 2 || m.peakRead != 1 || m.seeks != 0 {
t.Fatalf("results=%d read concurrency=%d seeks=%d", seen.Load(), m.peakRead, m.seeks)
}
}
func TestPipelineLateCloseNoSpaceKeepsAlreadyStartedOutcome(t *testing.T) {
// The first late Close fails only after the second Write has entered; the third must stay unstarted.
root := t.TempDir()
src := writeSourceFile(t, root, "source", []byte("late close"))
a, b, d := filepath.Join(root, "a"), filepath.Join(root, "b"), filepath.Join(root, "d")
secondStarted, secondGate := make(chan struct{}), make(chan struct{})
releaseSecond := onceRelease(secondGate)
t.Cleanup(releaseSecond)
m := newLTFSModel(ltfsModelConfig{before: func(op, path string) {
if op == "write" && path == b {
close(secondStarted)
<-secondGate
}
}, closeError: func(path string) error {
if path == a {
<-secondStarted
return syscall.ENOSPC
}
return nil
}})
defer m.shutdown()
results := make(chan Result, 3)
c, err := NewStream(context.Background(), func(batch []Result) error {
for _, r := range batch {
results <- r
}
return nil
}, WithHashPolicy(HashRead), WithResultBatch(1), SetToDevice(LinearDevice(true)))
if err != nil {
t.Fatal(err)
}
c.filesystem = ltfsFilesystem{transferFilesystem: c.fs(), model: m}
if err := c.Submit(&SimpleJob{Path: src, Dsts: []string{a}}, &SimpleJob{Path: src, Dsts: []string{b}}, &SimpleJob{Path: src, Dsts: []string{d}}); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() { _ = c.Close(); close(done) }()
first := <-results
if len(first.Targets) != 1 || first.Targets[0].Path != a || !errors.Is(first.Targets[0].Err, syscall.ENOSPC) || !errors.Is(first.Targets[0].Err, ErrTargetNoSpace) {
t.Fatalf("first result: %+v", first)
}
releaseSecond()
awaitStage(t, done, "late-error drain")
if err := c.Wait(); err != nil {
t.Fatal(err)
}
// Already admitted work reports its real success; later work never allocates an output.
outcomes := map[string]Result{a: first}
for range 2 {
r := <-results
outcomes[r.Job.(*SimpleJob).Dsts[0]] = r
}
if outcomes[b].Err != nil || outcomes[b].Targets[0].Err != nil || !errors.Is(outcomes[d].Err, ErrTargetNoSpace) {
t.Fatalf("outcomes: %+v", outcomes)
}
if m.outputs[d] != nil || !m.outputs[a].discarded || m.outputs[a].committed || !m.outputs[b].committed {
t.Fatal("late Close publication/admission mismatch")
}
if err := m.validate(); err != nil {
t.Fatal(err)
}
}
func TestPipelineRandomTargetsWriteConcurrently(t *testing.T) {
// Two target consumers must reach Write together even with only one active file.
root := t.TempDir()
src := writeSourceFile(t, root, "source", []byte("fanout"))
gate := make(chan struct{})
release := onceRelease(gate)
t.Cleanup(release)
entered := make(chan struct{}, 2)
m := newLTFSModel(ltfsModelConfig{capture: true, before: func(op, path string) {
if op == "write" {
entered <- struct{}{}
<-gate
}
}})
defer m.shutdown()
c, err := NewStream(context.Background(), func([]Result) error { return nil }, WithHashPolicy(HashRead), SetToDevice(DeviceThreads(1)))
if err != nil {
t.Fatal(err)
}
c.filesystem = ltfsFilesystem{transferFilesystem: c.fs(), model: m}
if err := c.Submit(&SimpleJob{Path: src, Dsts: []string{filepath.Join(root, "a"), filepath.Join(root, "b")}}); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() { _ = c.Close(); close(done) }()
awaitStage(t, entered, "first random target Write")
awaitStage(t, entered, "second random target Write")
release()
awaitStage(t, done, "fanout drain")
if err := c.Wait(); err != nil {
t.Fatal(err)
}
if err := m.validate(); err != nil {
t.Fatal(err)
}
if m.peakWrite != 2 {
t.Fatalf("target concurrency=%d", m.peakWrite)
}
}
func TestReadAheadWindowAdmitsPrefixOfNextLargeFile(t *testing.T) {
// A four-GiB head exposes only the next file's eligible prefix as the writer approaches its end.
c := newTestStream(t, SetToDevice(LinearDevice(true)))
ahead := newReadAhead(c)
const next = int64(4 << 30)
const prefix = int64(100 << 20)
ahead.advance(0, next-readAheadBytes+prefix)
for offset := int64(0); offset < prefix; offset += batchSize {
n, err := ahead.wait(1, next+offset, batchSize, false, nil)
if err != nil || n != batchSize {
t.Fatalf("prefix at %d: %d/%v", offset, n, err)
}
}
blocked := make(chan struct{})
go func() { _, _ = ahead.wait(1, next+prefix, batchSize, false, nil); close(blocked) }()
select {
case <-blocked:
t.Fatal("prefetch exceeded the logical window")
default:
}
ahead.advance(0, next-readAheadBytes+prefix+batchSize)
awaitStage(t, blocked, "sliding window eligibility")
}
func TestReadAheadSingleReaderReservesHeadOpportunity(t *testing.T) {
// The sole read permit cannot be occupied by speculative work ahead of the ordered head.
synctest.Test(t, func(t *testing.T) {
c := &StreamCopyer{option: &option{fromDevice: &deviceOption{threads: 1}, toDevice: &deviceOption{linear: true}}, hardStop: make(chan struct{})}
ahead := newReadAhead(c)
future := make(chan struct{})
go func() { release, _ := ahead.readSlot(1, nil); release(); close(future) }()
synctest.Wait()
select {
case <-future:
t.Fatal("future took the head's only read slot")
default:
}
// The foreground source retains priority until its reading finishes, independent of Write.
release, err := ahead.readSlot(0, nil)
if err != nil {
t.Fatal(err)
}
release()
ahead.finishRead(0)
<-future
})
}
func TestPipelineResultBatchLargerThanLifetimeLimit(t *testing.T) {
// The lightweight result buffer must not retain file slots until the caller's large batch fills.
root := t.TempDir()
source := writeSourceFile(t, root, "source", nil)
const total = transferLimit * 3
seen := 0
c, err := NewStream(context.Background(), func(results []Result) error { seen += len(results); return nil }, WithResultBatch(total), WithResultBuffer(1), WithResultFlushInterval(time.Hour))
if err != nil {
t.Fatal(err)
}
for range total {
if err := c.Submit(&SimpleJob{Path: source}); err != nil {
t.Fatal(err)
}
}
if err := c.Wait(); err != nil {
t.Fatal(err)
}
if seen != total {
t.Fatalf("results=%d want=%d", seen, total)
}
}
func TestReadAheadBackingCreditsWaitForLastConsumer(t *testing.T) {
// Exhaust speculative backing capacity while retaining the foreground reservation.
synctest.Test(t, func(t *testing.T) {
c := &StreamCopyer{option: &option{fromDevice: &deviceOption{threads: 8}, toDevice: &deviceOption{linear: true}}, hardStop: make(chan struct{})}
ahead := newReadAhead(c)
var held []*chunkBuffer
for range readAheadChunks - headChunks {
chunk, err := ahead.acquire(1, nil)
if err != nil {
t.Fatal(err)
}
held = append(held, chunk)
}
blocked := make(chan *chunkBuffer, 1)
go func() { chunk, _ := ahead.acquire(1, nil); blocked <- chunk }()
synctest.Wait()
select {
case <-blocked:
t.Fatal("future consumed the foreground reservation")
default:
}
for range headChunks {
chunk, err := ahead.acquire(0, nil)
if err != nil {
t.Fatal(err)
}
held = append(held, chunk)
}
// A shared reference remains charged until both consumers release the same backing allocation.
shared := held[0].retain()
held[0].release()
held[0] = nil
synctest.Wait()
select {
case <-blocked:
t.Fatal("backing credit released before the final consumer")
default:
}
shared.release()
replacement := <-blocked
if ahead.allocated != readAheadChunks || cap(replacement.data) != batchSize {
t.Fatalf("backing grew outside budget: allocated=%d capacity=%d", ahead.allocated, cap(replacement.data))
}
replacement.release()
for _, chunk := range held {
if chunk != nil {
chunk.release()
}
}
if ahead.used != 0 || ahead.futureUsed != 0 {
t.Fatalf("leaked credits: used=%d future=%d", ahead.used, ahead.futureUsed)
}
// A settled stream must detach pooled backing so it cannot retain the old stream and its jobs.
ahead.close()
for _, chunk := range append(held, shared, replacement) {
if chunk != nil && (chunk.owner != nil || chunk.future || chunk.refs != 0) {
t.Fatalf("settled backing still owns stream resources: owner=%p future=%t refs=%d", chunk.owner, chunk.future, chunk.refs)
}
}
})
}
+283
View File
@@ -0,0 +1,283 @@
package acp
import (
"context"
"errors"
"fmt"
"io"
"sync"
"sync/atomic"
"syscall"
"testing"
"testing/synctest"
"time"
mapset "github.com/deckarep/golang-set/v2"
"github.com/sirupsen/logrus"
)
// generatedReader exercises real pipeline credits without allocating a source-sized fixture.
type generatedReader struct {
remaining int64
read func(int)
closed atomic.Bool
}
func (r *generatedReader) Read(p []byte) (int, error) {
if r.remaining == 0 {
return 0, io.EOF
}
n := min(int64(len(p)), r.remaining)
r.remaining -= n
if r.read != nil {
r.read(int(n))
}
return int(n), nil
}
func (r *generatedReader) Close() error { r.closed.Store(true); return nil }
type pipelineFilesystem struct {
transferFilesystem
create func(*writeJob, targetSpec) (transferOutput, error)
}
func (f pipelineFilesystem) Create(job *writeJob, spec targetSpec) (transferOutput, error) {
return f.create(job, spec)
}
type pipelineOutput struct {
write func([]byte) (int, error)
close func() error
commit func() error
}
func (o *pipelineOutput) Write(p []byte) (int, error) { return o.write(p) }
func (o *pipelineOutput) Close() error {
if o.close != nil {
return o.close()
}
return nil
}
func (*pipelineOutput) Sync() error { return nil }
func (*pipelineOutput) Cache(*baseJob, bool) {}
func (*pipelineOutput) Restore(*stat) error { return nil }
func (*pipelineOutput) Discard() error { return nil }
func (o *pipelineOutput) Commit(bool) error {
if o.commit != nil {
return o.commit()
}
return nil
}
func preparedFixture(c *StreamCopyer, order uint64, size int64, target string, reader io.ReadCloser) *writeJob {
name := fmt.Sprintf("source-%d", order)
return newWriteJob(&baseJob{copyer: c, item: &SimpleJob{Path: name, Dsts: []string{target}},
path: name, order: order, stat: &stat{size: size}, targets: []string{target},
outputs: []targetSpec{{name: target, path: target, device: "fixture"}}}, reader)
}
func TestPipelineFailedCreateCancelsFullPrefetchWindow(t *testing.T) {
// Reject the target only after the source is parked beyond the complete 512 MiB window.
synctest.Test(t, func(t *testing.T) {
c := newTestStream(t, WithHashPolicy(HashOff), SetToDevice(LinearDevice(true)))
prefetched := make(chan struct{})
var once sync.Once
var read int64
reader := &generatedReader{remaining: readAheadBytes + batchSize, read: func(n int) {
read += int64(n)
if read >= readAheadBytes {
once.Do(func() { close(prefetched) })
}
}}
c.filesystem = pipelineFilesystem{create: func(*writeJob, targetSpec) (transferOutput, error) {
<-prefetched
synctest.Wait()
return nil, syscall.EIO
}}
prepared := make(chan *writeJob, 1)
prepared <- preparedFixture(c, 0, reader.remaining, "target", reader)
close(prepared)
// A transfer-local stop must finish without requiring a separate pipeline hard stop.
var expired atomic.Bool
guard := time.AfterFunc(time.Second, func() { expired.Store(true); c.stopHard() })
defer guard.Stop()
var results []Result
for job := range c.copy(context.Background(), prepared) {
results = append(results, job.result())
}
if expired.Load() || !reader.closed.Load() || len(results) != 1 {
t.Fatalf("failed target did not drain locally: timeout=%t closed=%t results=%d", expired.Load(), reader.closed.Load(), len(results))
}
if !errors.Is(results[0].Targets[0].Err, ErrTargetIO) {
t.Fatalf("target failure lost: %+v", results[0])
}
})
}
func TestPipelineRandomWritersReserveProgressBeforeReading(t *testing.T) {
for _, linear := range []bool{false, true} {
t.Run(fmt.Sprintf("linear-source=%t", linear), func(t *testing.T) {
// Later sources cannot retain all backing while their writer has no data permit.
synctest.Test(t, func(t *testing.T) {
c := newTestStream(t, WithHashPolicy(HashOff), SetFromDevice(LinearDevice(linear)), SetToDevice(DeviceThreads(1)))
started, release := make(chan struct{}), make(chan struct{})
var once sync.Once
var laterReads, written atomic.Int64
c.filesystem = pipelineFilesystem{create: func(job *writeJob, _ targetSpec) (transferOutput, error) {
return &pipelineOutput{write: func(data []byte) (int, error) {
if job.order == 0 {
once.Do(func() { close(started) })
<-release
}
written.Add(int64(len(data)))
return len(data), nil
}}, nil
}}
const size = readAheadBytes + batchSize
prepared := make(chan *writeJob, 3)
var readers []*generatedReader
for i := range 3 {
r := &generatedReader{remaining: size}
if i != 0 {
r.read = func(n int) { laterReads.Add(int64(n)) }
}
readers = append(readers, r)
prepared <- preparedFixture(c, uint64(i), size, fmt.Sprintf("target-%d", i), r)
}
close(prepared)
out := c.copy(context.Background(), prepared)
<-started
synctest.Wait()
if n := laterReads.Load(); n != 0 {
t.Errorf("unadmitted readers retained %d bytes", n)
}
// Once the first writer resumes, every source and target must settle without losing bytes.
close(release)
count := 0
for job := range out {
count++
result := job.result()
if result.Err != nil || result.Targets[0].Err != nil {
t.Errorf("unexpected copy failure: %+v", result)
}
}
if count != 3 || written.Load() != 3*size {
t.Fatalf("incomplete copies: files=%d bytes=%d", count, written.Load())
}
for _, r := range readers {
if !r.closed.Load() {
t.Fatal("source was not closed")
}
}
})
})
}
}
func TestPipelineFailedMiddleTargetPreservesPublicationOrder(t *testing.T) {
for _, failure := range []string{"create", "write", "close"} {
t.Run(failure, func(t *testing.T) {
// Hold A's Close while B fails and C finishes its data for the same target path.
synctest.Test(t, func(t *testing.T) {
c := newTestStream(t, WithHashPolicy(HashOff), SetToDevice(LinearDevice(true)))
release, thirdWrite := make(chan struct{}), make(chan struct{})
commits := make(chan uint64, 3)
var once sync.Once
c.filesystem = pipelineFilesystem{create: func(job *writeJob, _ targetSpec) (transferOutput, error) {
if job.order == 1 && failure == "create" {
return nil, syscall.EIO
}
return &pipelineOutput{
write: func(data []byte) (int, error) {
if job.order == 2 {
once.Do(func() { close(thirdWrite) })
}
if job.order == 1 && failure == "write" {
return 0, syscall.EIO
}
return len(data), nil
},
close: func() error {
if job.order == 0 {
<-release
}
if job.order == 1 && failure == "close" {
return syscall.EIO
}
return nil
},
commit: func() error { commits <- job.order; return nil },
}, nil
}}
prepared := make(chan *writeJob, 3)
for i := range 3 {
prepared <- preparedFixture(c, uint64(i), 4096, "same-target", &generatedReader{remaining: 4096})
}
close(prepared)
out := c.copy(context.Background(), prepared)
<-thirdWrite
synctest.Wait()
if len(commits) != 0 {
t.Error("C published before A's pending completion")
}
// A failed middle entry must not let an older successful copy overwrite the newest one.
close(release)
failures, count := 0, 0
for job := range out {
count++
if job.result().Targets[0].Err != nil {
failures++
}
}
close(commits)
var order []uint64
for i := range commits {
order = append(order, i)
}
if count != 3 || failures != 1 || len(order) != 2 || order[0] != 0 || order[1] != 2 {
t.Fatalf("wrong same-path outcome: count=%d failures=%d publications=%v", count, failures, order)
}
})
})
}
}
// panicLogHook represents a caller-supplied logger that fails while an I/O error is reported.
type panicLogHook struct{}
func (panicLogHook) Levels() []logrus.Level { return logrus.AllLevels }
func (panicLogHook) Fire(*logrus.Entry) error { panic("injected log hook panic") }
func TestPipelinePublicationReleasesAfterLoggingPanic(t *testing.T) {
// Both outputs already own publication tickets when the first Close reports an error.
logger := logrus.New()
logger.SetOutput(io.Discard)
logger.AddHook(panicLogHook{})
c := newTestStream(t, WithHashPolicy(HashOff), WithLogger(logger), SetToDevice(LinearDevice(true)))
order := newPublicationOrder()
first, second := order.reserve("same-target"), order.reserve("same-target")
job := preparedFixture(c, 0, 0, "same-target", &generatedReader{})
tr := newTransfer(c, job, newReadAhead(c), 0)
target := &targetTransfer{spec: job.outputs[0], publication: first,
output: &pipelineOutput{close: func() error { return syscall.EIO }}}
// Even a secondary panic in failure reporting must release the next file's dependency.
var recovered any
func() {
defer func() { recovered = recover() }()
tr.finishTarget(target, mapset.NewSet[string]())
}()
if recovered == nil {
t.Fatal("error logger did not exercise the panic boundary")
}
select {
case <-second.previous:
second.release()
default:
t.Fatal("logging panic stranded the next publication")
}
}
+50 -33
View File
@@ -51,8 +51,7 @@ var openSourceContent = func(path string, mode ReadMode, info os.FileInfo) (item
}
// 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.
// descriptor supplies cached metadata and content once; computed cache refresh can follow closure by path.
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.
@@ -69,7 +68,7 @@ func (c *StreamCopyer) prepareItem(job *baseJob) *writeJob {
if !needsContent {
mode = ReadBuffered
}
source, err := openSourceContent(job.path, mode, job.stat.info)
source, err := c.fs().Open(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.
@@ -85,7 +84,7 @@ func (c *StreamCopyer) prepareItem(job *baseJob) *writeJob {
// 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)
return newWriteJob(job, nil)
}
// Retain ownership until the prepared job takes it, including a cache lookup panic.
@@ -110,8 +109,9 @@ func (c *StreamCopyer) prepareItem(job *baseJob) *writeJob {
}
// Transfer the descriptor and indexed facts to the content stage.
wj := newWriteJob(job, source.reader, c.fromDevice.linear)
wj := newWriteJob(job, source.reader)
wj.source = source.file
wj.sourcePath = job.path
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
@@ -126,7 +126,7 @@ func (c *StreamCopyer) prepareItem(job *baseJob) *writeJob {
// 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 := newWriteJob(job, nil)
wj.skipContent = true
return wj
}
@@ -139,11 +139,9 @@ func (c *StreamCopyer) prepare(ctx context.Context, indexed <-chan *baseJob) <-c
// 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.
// Bound opened sources through their complete transfer lifetime, independently of data lanes.
chanLen := 32
if c.fromDevice.linear {
chanLen = 0
}
slots := make(chan struct{}, transferLimit)
// Collect every preparation outcome so skipped jobs can still advance linear ordering.
completed := make(chan prepareResult, chanLen)
@@ -151,34 +149,50 @@ func (c *StreamCopyer) prepare(ctx context.Context, indexed <-chan *baseJob) <-c
var workers sync.WaitGroup
// Prepare source readers with the configured source-device concurrency.
for idx := 0; idx < c.fromDevice.threads; idx++ {
for idx := 0; idx < min(max(c.fromDevice.threads, 8), transferLimit); 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) {
for {
// Reserve admission before dequeue so an earlier job cannot wait behind later jobs.
select {
case slots <- struct{}{}:
case <-c.hardStop:
return
}
if !result.job.waitConsumed() {
// An idle reservation owns no job and must return its slot on exhaustion or failure.
var job *baseJob
select {
case next, ok := <-indexed:
if !ok {
<-slots
return
}
job = next
case <-c.hardStop:
<-slots
return
}
result := prepareResult{order: job.order, release: make(chan struct{})}
transferred := false
func() {
defer func() {
if !transferred {
<-slots
}
}()
if c.markUnstarted(ctx, job) {
result.job = newWriteJob(job, nil)
} else {
result.job = c.prepareItem(job)
}
result.job.release = func() { <-slots }
transferred = true
}()
if !c.sendPrepareResult(completed, result) {
return
}
}
@@ -226,6 +240,7 @@ func (result prepareResult) releaseWorker() {
func (result prepareResult) finish() {
if result.job != nil {
result.job.finishSource()
result.job.releaseSlot()
}
result.releaseWorker()
}
@@ -243,14 +258,16 @@ func (c *StreamCopyer) forwardPrepared(
}
}()
// Random targets retain completion-order concurrency.
if !c.toDevice.linear {
// Concurrent random devices retain completion-order preparation.
serial := c.fromDevice.threads == 1 && c.toDevice.threads == 1
if !c.toDevice.linear && !c.fromDevice.linear && !serial {
for result := range completed {
if result.job == nil {
continue
}
select {
case prepared <- result.job:
result.releaseWorker()
case <-c.hardStop:
result.finish()
return
@@ -259,7 +276,7 @@ func (c *StreamCopyer) forwardPrepared(
return
}
// A bounded reorder buffer feeds the linear writer in original request order.
// A bounded reorder buffer retains the request order of formerly serialized devices.
next := uint64(0)
pending := make(map[uint64]prepareResult, c.fromDevice.threads)
defer func() {
+268
View File
@@ -0,0 +1,268 @@
package acp
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"hash"
"io"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
type sumHookHash struct {
hash.Hash
onSum func([]byte)
}
func (h *sumHookHash) Sum(prefix []byte) []byte {
// Observe completed hashes without changing the implementation's bytes.
result := h.Hash.Sum(prefix)
h.onSum(result)
return result
}
type countedSource struct {
io.ReadCloser
bytes *atomic.Int64
closes *atomic.Int32
}
func (r *countedSource) Read(data []byte) (int, error) {
// Count actual source bytes, excluding later in-memory delivery.
n, err := r.ReadCloser.Read(data)
r.bytes.Add(int64(n))
return n, err
}
func (r *countedSource) Close() error {
// Record every close attempt while preserving the real descriptor error.
r.closes.Add(1)
return r.ReadCloser.Close()
}
func TestLinearCopyPreparesHashWhilePriorTargetFinishes(t *testing.T) {
// Observe the second hash while the first target is still completing metadata.
trackChunkPool(t)
root := t.TempDir()
first := bytes.Repeat([]byte{'a'}, 256<<10)
second := bytes.Repeat([]byte{'b'}, 256<<10)
wantSecond := sha256.Sum256(second)
one := newFixtureItem(writeSourceFile(t, root, "one", first), filepath.Join(root, "target-one"))
two := newFixtureItem(writeSourceFile(t, root, "two", second), filepath.Join(root, "target-two"))
blocked, release, secondHashed := make(chan struct{}), make(chan struct{}), make(chan struct{})
var signal sync.Once
var hashCount, closeCount atomic.Int32
var sourceBytes atomic.Int64
oldHash, oldRestore, oldOpen := sha256Pool, restoreTarget, openSourceContent
t.Cleanup(func() { sha256Pool, restoreTarget, openSourceContent = oldHash, oldRestore, oldOpen })
sha256Pool = &sync.Pool{New: func() interface{} {
return &sumHookHash{Hash: sha256.New(), onSum: func(value []byte) {
hashCount.Add(1)
if bytes.Equal(value, wantSecond[:]) {
signal.Do(func() { close(secondHashed) })
}
}}
}}
openSourceContent = func(path string, mode ReadMode, info os.FileInfo) (itemSource, error) {
// Keep the actual descriptor so cache and close ownership remain exercised.
source, err := oldOpen(path, mode, info)
if err == nil {
source.reader = &countedSource{ReadCloser: source.reader, bytes: &sourceBytes, closes: &closeCount}
}
return source, err
}
restoreTarget = func(path string, info *stat) error {
// A target already receiving data must not stop later source preparation.
if info.info.Name() == "one" {
close(blocked)
<-release
}
return oldRestore(path, info)
}
// Run the existing ordered stream and always release and join it before restoring hooks.
done := make(chan error, 1)
go func() {
done <- runFixture(context.Background(), newStreamFixture(one, two), []Item{one, two},
WithHashPolicy(HashReadRefresh), SetToDevice(LinearDevice(true)))
}()
select {
case <-blocked:
case <-time.After(5 * time.Second):
t.Error("first target did not reach metadata completion")
}
select {
case <-secondHashed:
case <-time.After(time.Second):
t.Error("next file hash remained blocked behind prior target completion")
}
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
// Every source byte is read once and hashed once; both targets retain correct outcomes.
if sourceBytes.Load() != int64(len(first)+len(second)) || hashCount.Load() != 2 || closeCount.Load() != 2 {
t.Fatalf("source bytes=%d hashes=%d closes=%d", sourceBytes.Load(), hashCount.Load(), closeCount.Load())
}
for index, item := range []*fixtureItem{one, two} {
content := [][]byte{first, second}[index]
want := sha256.Sum256(content)
result, err := item.terminal(t)
if err != nil || !bytes.Equal(result.SHA256, want[:]) || len(result.Targets) != 1 || result.Targets[0].Err != nil {
t.Fatalf("unexpected result: %+v / %v", result, err)
}
actual, err := os.ReadFile(item.targets[0])
if err != nil || !bytes.Equal(actual, content) {
t.Fatalf("target content differs: %v", err)
}
}
}
func TestLinearCopyPreservesHashWhenTargetsRejectPreparation(t *testing.T) {
// A rejected target must not discard the hash or source-cache work completed for the item.
requireSignatureXattrSupport(t)
trackChunkPool(t)
root := t.TempDir()
content := bytes.Repeat([]byte{'r'}, 256<<10)
source := writeSourceFile(t, root, "source", content)
item := newFixtureItem(source, filepath.Join(root, "target"))
want := sha256.Sum256(content)
copyer, err := NewStream(context.Background(), newStreamFixture(item).onResults,
WithHashPolicy(HashReadRefresh), SetToDevice(LinearDevice(true)))
if err != nil {
t.Fatal(err)
}
sentinel := errors.New("capacity observation failed")
copyer.availableSpace = func(string) (int64, error) { return 0, sentinel }
// Fail before output creation and still finish the ordinary source lifecycle.
if err := copyer.Submit(item); err != nil {
t.Fatal(err)
}
if err := copyer.Close(); err != nil {
t.Fatal(err)
}
if err := copyer.Wait(); err != nil {
t.Fatal(err)
}
result, err := item.terminal(t)
if err != nil || !bytes.Equal(result.SHA256, want[:]) || !errors.Is(result.Targets[0].Err, sentinel) {
t.Fatalf("hash/target failure changed: %+v / %v", result, err)
}
// Source refresh stays valid even though no destination accepted this file.
file, err := os.Open(source)
if err != nil {
t.Fatal(err)
}
defer file.Close()
encoded, err := readSignatureXattr(file)
if err != nil {
t.Fatal(err)
}
stored, err := DecodeCachedSignature(encoded)
if err != nil || stored.SHA256 != want || stored.Size != int64(len(content)) {
t.Fatalf("source signature = %+v / %v", stored, err)
}
}
type preparationFaultReader struct {
io.ReadCloser
read func([]byte) (int, error)
}
func (r *preparationFaultReader) Read(data []byte) (int, error) {
return r.read(data)
}
func TestLinearPreparedContentReleasesFailures(t *testing.T) {
for _, failure := range []string{"read error", "read panic", "hash panic", "close error"} {
t.Run(failure, func(t *testing.T) {
// Inject failures into the retained original source and observe its one close.
captureLogs(t)
trackChunkPool(t)
root := t.TempDir()
content := bytes.Repeat([]byte{'f'}, 4096)
item := newFixtureItem(writeSourceFile(t, root, "source", content), filepath.Join(root, "target"))
sentinel := errors.New("prepared source failed")
var sourceBytes atomic.Int64
var closes atomic.Int32
oldOpen, oldHash := openSourceContent, sha256Pool
t.Cleanup(func() { openSourceContent, sha256Pool = oldOpen, oldHash })
openSourceContent = func(path string, mode ReadMode, info os.FileInfo) (itemSource, error) {
// Wrap the same descriptor; errors and panics must not leak it or its content buffer.
source, err := oldOpen(path, mode, info)
if err != nil {
return source, err
}
tracked := &countedSource{ReadCloser: source.reader, bytes: &sourceBytes, closes: &closes}
source.reader = tracked
switch failure {
case "read error":
source.reader = &preparationFaultReader{ReadCloser: tracked, read: func(data []byte) (int, error) {
return copy(data, content[:17]), sentinel
}}
case "read panic":
source.reader = &preparationFaultReader{ReadCloser: tracked, read: func([]byte) (int, error) {
panic(sentinel)
}}
case "close error":
source.reader = &errorOnClose{ReadCloser: tracked, err: sentinel}
}
return source, nil
}
if failure == "hash panic" {
sha256Pool = &sync.Pool{New: func() interface{} { return panickingHash{Hash: sha256.New()} }}
sentinel = errPanickingHash
}
// Complete the real stream so error replay, cache policy and cleanup all run.
err := runFixture(context.Background(), newStreamFixture(item), []Item{item},
WithHashPolicy(HashReadRefresh), SetToDevice(LinearDevice(true)))
if closes.Load() != 1 {
t.Fatalf("source close count = %d, want 1", closes.Load())
}
if failure == "read panic" || failure == "hash panic" {
if !errors.Is(err, sentinel) {
t.Fatalf("run error = %v, want panic %v", err, sentinel)
}
return
}
// Ordinary read failures stay target errors; source-close failures stay item errors.
if err != nil {
t.Fatal(err)
}
result, itemErr := item.terminal(t)
if failure == "close error" {
if !errors.Is(itemErr, sentinel) {
t.Fatalf("item error = %v, want source-close failure", itemErr)
}
return
}
if itemErr != nil || len(result.SHA256) != 0 || !errors.Is(result.Targets[0].Err, sentinel) {
t.Fatalf("partial read outcome = %+v / %v", result, itemErr)
}
if _, err := os.Stat(item.targets[0]); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("failed target remains: %v", err)
}
})
}
}
type errorOnClose struct {
io.ReadCloser
err error
}
func (r *errorOnClose) Close() error {
// Release the real resource before reporting the injected close failure.
return errors.Join(r.ReadCloser.Close(), r.err)
}
+263
View File
@@ -0,0 +1,263 @@
package acp
import (
"context"
"errors"
"os"
"sync"
"syscall"
"testing"
"testing/synctest"
"github.com/samuelncui/acp/internal/fileio"
)
type serialPublicationOutput struct {
*pipelineOutput
sync func() error
publish func(bool) error
}
func (o *serialPublicationOutput) Sync() error { return o.sync() }
func (o *serialPublicationOutput) Commit(overwrite bool) error { return o.publish(overwrite) }
func TestSerialRandomTargetPreservesConflictingPublicationOrder(t *testing.T) {
// Exercise each configuration that previously completed conflicting copies in a fixed order.
for _, config := range []struct {
name string
sourceLinear bool
targetThreads int
}{
{"linear-source-serial-target", true, 1},
{"random-source-serial-target", false, 1},
{"linear-source-parallel-target", true, 3},
} {
t.Run(config.name, func(t *testing.T) {
for _, overwrite := range []bool{false, true} {
t.Run(map[bool]string{false: "exclusive", true: "overwrite"}[overwrite], func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Delay the first completion while later data and a distinct publication advance.
c := newTestStream(t, WithHashPolicy(HashOff), Overwrite(overwrite),
SetFromDevice(LinearDevice(config.sourceLinear), DeviceThreads(1)), SetToDevice(DeviceThreads(config.targetThreads)))
release, independent := make(chan struct{}), make(chan struct{})
laterCreated := make(chan struct{})
var lock sync.Mutex
published := make(map[string]uint64)
c.filesystem = pipelineFilesystem{create: func(job *writeJob, spec targetSpec) (transferOutput, error) {
if config.targetThreads > 1 {
if job.order == 0 {
<-laterCreated
}
if job.order == 1 {
close(laterCreated)
}
}
return &serialPublicationOutput{
pipelineOutput: &pipelineOutput{write: func(data []byte) (int, error) { return len(data), nil }},
sync: func() error {
if job.order == 0 {
<-release
}
return nil
},
publish: func(overwrite bool) error {
lock.Lock()
defer lock.Unlock()
if _, exists := published[spec.path]; exists && !overwrite {
return os.ErrExist
}
published[spec.path] = job.order
if job.order == 2 {
close(independent)
}
return nil
},
}, nil
}}
prepared := make(chan *writeJob, 3)
for i, target := range []string{"same-target", "same-target", "independent-target"} {
prepared <- preparedFixture(c, uint64(i), 4096, target, &generatedReader{remaining: 4096})
}
close(prepared)
out := c.copy(context.Background(), prepared)
<-independent
synctest.Wait()
lock.Lock()
_, premature := published["same-target"]
lock.Unlock()
if premature {
t.Error("conflicting output published before the prior completion")
}
// The old single-writer winner remains first for exclusive creation and last for overwrite.
close(release)
failures, count := 0, 0
for job := range out {
count++
if err := job.result().Targets[0].Err; err != nil {
failures++
if !errors.Is(err, os.ErrExist) || job.order != 1 {
t.Errorf("unexpected target error: order=%d error=%v", job.order, err)
}
}
}
want, wantFailures := uint64(0), 1
if overwrite {
want, wantFailures = 1, 0
}
if count != 3 || failures != wantFailures || published["same-target"] != want {
t.Fatalf("publication outcomes: count=%d failures=%d winners=%v", count, failures, published)
}
})
})
}
})
}
}
func TestPublicationReleasesFailedAliasesAfterSourceCache(t *testing.T) {
// Failed outputs before and after a rewrite output retain their shared-path dependency chain.
synctest.Test(t, func(t *testing.T) {
c := newTestStream(t, WithHashPolicy(HashOff), SetToDevice(DeviceThreads(1)))
job := preparedFixture(c, 0, 4096, "same-target", &generatedReader{remaining: 4096})
job.targets = []string{"first", "rewrite", "last"}
job.outputs = []targetSpec{
{name: "first", path: "same-target", device: "fixture"},
{name: "rewrite", path: "same-target", device: "fixture", output: &fileio.Output{}},
{name: "last", path: "same-target", device: "fixture"},
}
committed := false
c.filesystem = pipelineFilesystem{create: func(_ *writeJob, spec targetSpec) (transferOutput, error) {
if spec.name != "rewrite" {
return nil, syscall.EIO
}
return &pipelineOutput{
write: func(data []byte) (int, error) { return len(data), nil },
commit: func() error { committed = true; return nil },
}, nil
}}
prepared := make(chan *writeJob, 1)
prepared <- job
close(prepared)
// Cleanup must finish the source cache before releasing failed tickets in original output order.
count := 0
for result := range c.copy(context.Background(), prepared) {
count++
targets := result.result().Targets
if len(targets) != 3 || targets[1].Err != nil ||
!errors.Is(targets[0].Err, ErrTargetIO) || !errors.Is(targets[2].Err, ErrTargetIO) {
t.Fatalf("alias outcomes: %+v", targets)
}
}
if count != 1 || !committed {
t.Fatalf("unfinished alias chain: count=%d committed=%t", count, committed)
}
})
}
func TestLinearSourceKeepsConflictingDirectOutputsExclusive(t *testing.T) {
// Raw devices may reject a second Open until the prior Close; Commit cannot hide their writes.
synctest.Test(t, func(t *testing.T) {
c := newTestStream(t, WithHashPolicy(HashOff), Overwrite(true),
SetFromDevice(LinearDevice(true)), SetToDevice(DeviceThreads(3)))
release, independent := make(chan struct{}), make(chan struct{})
var lock sync.Mutex
opened := make(map[string]bool)
c.filesystem = pipelineFilesystem{create: func(job *writeJob, spec targetSpec) (transferOutput, error) {
lock.Lock()
defer lock.Unlock()
if opened[spec.path] {
return nil, syscall.EBUSY
}
opened[spec.path] = true
return &serialPublicationOutput{
pipelineOutput: &pipelineOutput{write: func(data []byte) (int, error) { return len(data), nil }},
sync: func() error {
if job.order == 0 {
<-release
}
return nil
},
publish: func(bool) error {
lock.Lock()
defer lock.Unlock()
opened[spec.path] = false
if job.order == 2 {
close(independent)
}
return nil
},
}, nil
}}
prepared := make(chan *writeJob, 3)
for i, target := range []string{"raw-device", "raw-device", "independent-device"} {
job := preparedFixture(c, uint64(i), 4096, target, &generatedReader{remaining: 4096})
job.outputs[0].direct = true
prepared <- job
}
close(prepared)
out := c.copy(context.Background(), prepared)
<-independent
synctest.Wait()
// A separate device finishes while both same-device copies succeed in their original order.
close(release)
count := 0
for job := range out {
count++
if err := job.result().Targets[0].Err; err != nil {
t.Errorf("raw device copy failed: order=%d error=%v", job.order, err)
}
}
if count != 3 || opened["raw-device"] || opened["independent-device"] {
t.Fatalf("raw device outcomes: count=%d open=%v", count, opened)
}
})
}
func TestDirectOutputObservesNoSpaceAfterPriorClose(t *testing.T) {
// A later direct target waits for the first device handle, whose Close discovers capacity exhaustion.
synctest.Test(t, func(t *testing.T) {
c := newTestStream(t, WithHashPolicy(HashOff), SetToDevice(LinearDevice(true)))
closing, release := make(chan struct{}), make(chan struct{})
creates := 0
c.filesystem = pipelineFilesystem{create: func(job *writeJob, _ targetSpec) (transferOutput, error) {
creates++
return &pipelineOutput{
write: func(data []byte) (int, error) { return len(data), nil },
close: func() error {
if job.order == 0 {
close(closing)
<-release
return syscall.ENOSPC
}
return nil
},
}, nil
}}
prepared := make(chan *writeJob, 2)
for i := range 2 {
job := preparedFixture(c, uint64(i), 4096, "raw-device", &generatedReader{remaining: 4096})
job.outputs[0].direct = true
prepared <- job
}
close(prepared)
out := c.copy(context.Background(), prepared)
<-closing
synctest.Wait()
close(release)
// The authoritative Close error stops the waiting output before another Create.
count := 0
for job := range out {
count++
if err := job.result().Targets[0].Err; !errors.Is(err, ErrTargetNoSpace) {
t.Errorf("lost no-space outcome: order=%d error=%v", job.order, err)
}
}
if count != 2 || creates != 1 {
t.Fatalf("exhausted direct target admitted again: count=%d creates=%d", count, creates)
}
})
}
+287
View File
@@ -0,0 +1,287 @@
package acp
import (
"fmt"
"sync"
)
const (
transferLimit = 256
readAheadBytes = 512 << 20
readAheadChunks = readAheadBytes / batchSize
headChunks = 8
)
// readAhead owns one run's backing allocations, logical window and read permits.
// Its reservations protect the ordered head without changing item feed capacity.
type readAhead struct {
copyer *StreamCopyer
ordered bool
readers int
lock sync.Mutex
changed chan struct{}
free chan *chunkBuffer
head uint64
readHead uint64
readDone map[uint64]struct{}
position int64
allocated int
used int
futureUsed int
reading int
futureReading int
}
func newReadAhead(c *StreamCopyer) *readAhead {
// Preserve configured source concurrency, including single-reader sources.
readers := c.fromDevice.threads
if readers < 1 {
readers = 1
}
return &readAhead{
copyer: c, ordered: c.toDevice.linear, readers: readers,
free: make(chan *chunkBuffer, readAheadChunks),
}
}
func (b *readAhead) wait(
order uint64, position int64, want int, eof bool, stop <-chan struct{},
) (int, error) {
// Limit a read operation independently of its backing allocation.
if want <= 0 {
return 0, nil
}
want = min(want, batchSize)
for {
// Observe the frontier and subscribe before releasing its lock.
b.lock.Lock()
select {
case <-stop:
b.lock.Unlock()
return 0, fmt.Errorf("read-ahead stopped by transfer")
case <-b.copyer.hardStop:
b.lock.Unlock()
return 0, fmt.Errorf("read-ahead stopped by pipeline failure")
default:
}
if !b.ordered || (eof && order <= b.head) {
b.lock.Unlock()
if eof {
return 1, nil
}
return want, nil
}
distance := position - b.position
if distance < readAheadBytes {
allowed := want
if distance > 0 {
allowed = min(allowed, int(readAheadBytes-distance))
}
b.lock.Unlock()
return allowed, nil
}
changed := b.subscribe()
b.lock.Unlock()
// Either stop releases parked reads without waiting for another frontier change.
select {
case <-changed:
case <-stop:
return 0, fmt.Errorf("read-ahead stopped by transfer")
case <-b.copyer.hardStop:
return 0, fmt.Errorf("read-ahead stopped by pipeline failure")
}
}
}
func (b *readAhead) acquire(order uint64, stop <-chan struct{}) (*chunkBuffer, error) {
for {
// Allocate lazily under the same owner that accounts live backing credits.
b.lock.Lock()
select {
case <-stop:
b.lock.Unlock()
return nil, fmt.Errorf("read-ahead stopped by transfer")
case <-b.copyer.hardStop:
b.lock.Unlock()
return nil, fmt.Errorf("read-ahead stopped by pipeline failure")
default:
}
future := b.ordered && order > b.head
if b.used < readAheadChunks && (!future || b.futureUsed < readAheadChunks-headChunks) {
var chunk *chunkBuffer
select {
case chunk = <-b.free:
default:
// A test allocation hook may panic; never leave the owner locked.
func() {
defer func() {
if value := recover(); value != nil {
b.lock.Unlock()
panic(value)
}
}()
chunk = chunkPool.Get().(*chunkBuffer)
}()
b.allocated++
}
b.used++
if future {
b.futureUsed++
}
chunk.data = chunk.data[:cap(chunk.data)]
chunk.refs = 1
chunk.owner = b
chunk.future = future
b.lock.Unlock()
return chunk, nil
}
changed := b.subscribe()
b.lock.Unlock()
// Head promotion or either stop can release a backing wait without another buffer release.
select {
case <-changed:
case <-stop:
return nil, fmt.Errorf("read-ahead stopped by transfer")
case <-b.copyer.hardStop:
return nil, fmt.Errorf("read-ahead stopped by pipeline failure")
}
}
}
func (b *readAhead) release(chunk *chunkBuffer) {
// Last-reference release returns backing storage only to this run's bounded pool.
b.lock.Lock()
defer b.lock.Unlock()
b.used--
if chunk.future {
b.futureUsed--
}
chunk.future = false
b.free <- chunk
b.notify()
}
func (b *readAhead) close() {
// The dispatcher joins every child before returning this run's unreferenced backing.
for {
select {
case chunk := <-b.free:
chunk.owner = nil
chunk.future = false
chunkPool.Put(chunk)
default:
return
}
}
}
func (b *readAhead) advance(order uint64, position int64) {
// Random targets do not constrain reads by a consumed-content frontier.
if !b.ordered {
return
}
// Only the ordered writer moves the head and consumed-content frontier.
b.lock.Lock()
defer b.lock.Unlock()
if order < b.head || (order == b.head && position <= b.position) {
return
}
b.head = order
b.position = max(b.position, position)
b.notify()
}
func (b *readAhead) finishRead(order uint64) {
// Reader priority follows completed sources, independently of the writer's byte frontier.
if !b.ordered {
return
}
b.lock.Lock()
defer b.lock.Unlock()
if order != b.readHead {
if b.readDone == nil {
b.readDone = make(map[uint64]struct{})
}
b.readDone[order] = struct{}{}
return
}
// Completed speculative or empty sources cannot retain the next reader's reservation.
b.readHead++
for {
if _, done := b.readDone[b.readHead]; !done {
break
}
delete(b.readDone, b.readHead)
b.readHead++
}
b.notify()
}
func (b *readAhead) readSlot(order uint64, stop <-chan struct{}) (func(), error) {
for {
// Reserve one dispatch opportunity for the ordered head when concurrency permits.
b.lock.Lock()
select {
case <-stop:
b.lock.Unlock()
return nil, fmt.Errorf("read-ahead stopped by transfer")
case <-b.copyer.hardStop:
b.lock.Unlock()
return nil, fmt.Errorf("read-ahead stopped by pipeline failure")
default:
}
future := b.ordered && order > b.readHead
reserved := future && b.futureReading >= b.readers-1
if b.reading < b.readers && !reserved {
b.reading++
if future {
b.futureReading++
}
b.lock.Unlock()
return func() {
// The caller releases immediately after Read, before any channel wait.
b.lock.Lock()
defer b.lock.Unlock()
b.reading--
if future {
b.futureReading--
}
b.notify()
}, nil
}
changed := b.subscribe()
b.lock.Unlock()
// Read completion, head promotion or either stop wakes callers without polling.
select {
case <-changed:
case <-stop:
return nil, fmt.Errorf("read-ahead stopped by transfer")
case <-b.copyer.hardStop:
return nil, fmt.Errorf("read-ahead stopped by pipeline failure")
}
}
}
// subscribe observes the next state change while the caller holds the budget lock.
func (b *readAhead) subscribe() <-chan struct{} {
if b.changed == nil {
b.changed = make(chan struct{})
}
return b.changed
}
// notify releases every current waiter; the next waiter allocates its own notification.
// The caller holds the budget lock across its state change and this notification.
func (b *readAhead) notify() {
if b.changed == nil {
return
}
close(b.changed)
b.changed = nil
}
+101
View File
@@ -0,0 +1,101 @@
package acp
import (
"context"
"fmt"
"sync/atomic"
"testing"
"testing/synctest"
)
func TestPipelineSingleReaderPrefetchesAfterSourceEOF(t *testing.T) {
// Both random and linear sources can read sequentially ahead of a blocked linear writer.
for _, linear := range []bool{false, true} {
t.Run(fmt.Sprintf("linear-source=%t", linear), func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := newTestStream(t, WithHashPolicy(HashRead),
SetFromDevice(DeviceThreads(1), LinearDevice(linear)), SetToDevice(LinearDevice(true)))
gate, writing := make(chan struct{}), make(chan struct{})
release := onceRelease(gate)
defer release()
var written atomic.Int64
c.filesystem = pipelineFilesystem{create: func(job *writeJob, _ targetSpec) (transferOutput, error) {
return &pipelineOutput{write: func(p []byte) (int, error) {
if job.order == 0 {
close(writing)
<-gate
}
written.Add(int64(len(p)))
return len(p), nil
}}, nil
}}
const size = 4096
var reads [3]atomic.Int64
var readers [3]*generatedReader
prepared := make(chan *writeJob, len(readers))
for i := range readers {
readers[i] = &generatedReader{remaining: size, read: func(n int) { reads[i].Add(int64(n)) }}
prepared <- preparedFixture(c, uint64(i), size, fmt.Sprintf("target-%d", i), readers[i])
}
close(prepared)
out := c.copy(context.Background(), prepared)
<-writing
synctest.Wait()
// EOF moves the read reservation while Write remains blocked and the byte window has room.
for i, reader := range readers {
if reads[i].Load() != size || !reader.closed.Load() {
t.Errorf("source %d still waits for Write: bytes=%d closed=%t", i, reads[i].Load(), reader.closed.Load())
}
}
// Releasing the writer settles every hash and target without changing output accounting.
release()
count := 0
for job := range out {
count++
result := job.result()
if result.Err != nil || result.Targets[0].Err != nil || len(result.SHA256) != 32 {
t.Errorf("incomplete result: %+v", result)
}
}
if count != len(readers) || written.Load() != int64(len(readers)*size) {
t.Fatalf("incomplete transfer: files=%d bytes=%d", count, written.Load())
}
})
})
}
}
func TestReadAheadPrioritySkipsCompletedSourcesInOrder(t *testing.T) {
// Later empty or failed sources may finish before the foreground source has read its data.
synctest.Test(t, func(t *testing.T) {
c := newTestStream(t, SetFromDevice(DeviceThreads(1)), SetToDevice(LinearDevice(true)))
ahead := newReadAhead(c)
future := make(chan struct{})
go func() {
release, err := ahead.readSlot(3, nil)
if err == nil {
release()
}
close(future)
}()
ahead.finishRead(2)
ahead.finishRead(1)
synctest.Wait()
select {
case <-future:
t.Fatal("completed later sources skipped the unread foreground source")
default:
}
// Only completion of the earliest source releases the next remaining source's Read.
release, err := ahead.readSlot(0, nil)
if err != nil {
t.Fatal(err)
}
release()
ahead.finishRead(0)
<-future
})
}
+36 -13
View File
@@ -23,8 +23,9 @@ var errSignatureXattrUnsupported = errors.New("signature xattr is unsupported")
// The managed-xattr operations are indirected so a test can exercise the missing, unsupported
// and failing paths on a file system that accepts the attribute.
var (
readManagedXattr = readSignatureXattr
writeManagedXattr = writeSignatureXattr
readManagedXattr = readSignatureXattr
writeManagedXattr = writeSignatureXattr
writeManagedPathXattr = writeSignaturePathXattr
)
// signatureXattrIgnorable reports whether an xattr error leaves the cache in a well-defined
@@ -212,25 +213,45 @@ func (c *signatureCache) lookup(file *os.File, path string, indexed *stat) ([]by
// Sources and paths stay stable during a run. Preparation retained the source's stored entry;
// targets use fresh temporary files, so neither publication needs another metadata or xattr read.
func (c *StreamCopyer) refreshCacheEntry(file *os.File, path string, job *baseJob, target bool) {
// Only a completed content hash under a refresh policy can publish an entry.
if file == nil || c.signatures == nil || !c.hashPolicy.refreshesCache() {
// A missing descriptor means this item never opened the named file.
if file == nil {
return
}
// Share policy and snapshot decisions with publication after descriptor closure.
if signature, ok := c.cacheEntry(path, job, target); ok {
c.signatures.write(file, path, signature)
}
}
// refreshCachePath publishes through a stable, caller-owned path after its descriptor closes.
// The caller keeps the path unchanged until publication and any target replacement finish.
func (c *StreamCopyer) refreshCachePath(path string, job *baseJob, target bool) {
if signature, ok := c.cacheEntry(path, job, target); ok {
c.signatures.recordWrite(path, writeManagedPathXattr(path, encodeCachedSignature(signature)))
}
}
func (c *StreamCopyer) cacheEntry(path string, job *baseJob, target bool) (CachedSignature, bool) {
// Only a completed content hash under a refresh policy can publish an entry.
if c.signatures == nil || !c.hashPolicy.refreshesCache() {
return CachedSignature{}, false
}
hash, ok := job.computedHash()
if !ok {
return
return CachedSignature{}, false
}
signature, err := newCachedSignature(hash, job.stat)
if err != nil {
c.signatures.recordFailure(path, err)
return
return CachedSignature{}, false
}
// Avoid rewriting an equal source entry; a fresh target has no stored entry to inspect.
// Preparation retained the source entry; fresh targets have no equal entry to skip.
if !target && job.cachedSignature != nil && *job.cachedSignature == signature {
return
return CachedSignature{}, false
}
c.signatures.write(file, path, signature)
return signature, true
}
// write publishes one computed signature through the descriptor that owns the file. The snapshot
@@ -239,10 +260,12 @@ func (c *StreamCopyer) refreshCacheEntry(file *os.File, path string, job *baseJo
// bind the hash to a version it never described. Every failure is an aggregate warning, because a
// cache that cannot store an entry never fails the item that computed it.
func (c *signatureCache) write(file *os.File, path string, signature CachedSignature) {
// Publish the item's facts directly; unsupported attributes remain a no-op.
if err := writeManagedXattr(file, encodeCachedSignature(signature)); err != nil {
// A file system without the managed attribute namespace cannot hold the cache at all:
// that is a no-op, not a failure of this run.
c.recordWrite(path, writeManagedXattr(file, encodeCachedSignature(signature)))
}
func (c *signatureCache) recordWrite(path string, err error) {
// Descriptor and path publication share unsupported handling and aggregate warnings.
if err != nil {
if signatureXattrIgnorable(err) {
return
}
+44 -38
View File
@@ -326,6 +326,7 @@ func TestRunSignatureCacheAccessCounts(t *testing.T) {
// Track the one source descriptor and every cache access, including target writers.
previousOpen, previousRead, previousWrite := openSourceContent, readManagedXattr, writeManagedXattr
previousPathWrite := writeManagedPathXattr
var lock sync.Mutex
var sourceFile *os.File
var opens, reads, sourceWrites, targetWrites int
@@ -376,8 +377,20 @@ func TestRunSignatureCacheAccessCounts(t *testing.T) {
}
return nil
}
writeManagedPathXattr = func(path string, value []byte) error {
// The asynchronous source cache describes the same stable file after Close.
got, err := DecodeCachedSignature(value)
if path != input || err != nil || got != want {
t.Errorf("path cache = %q / %#v / %v", path, got, err)
}
lock.Lock()
sourceWrites++
lock.Unlock()
return nil
}
t.Cleanup(func() {
openSourceContent, readManagedXattr, writeManagedXattr = previousOpen, previousRead, previousWrite
writeManagedPathXattr = previousPathWrite
})
// Wait for the full item so the counts include publication and descriptor release.
@@ -816,12 +829,13 @@ func TestRunSignatureCacheIsBestEffortForReadOnlyFile(t *testing.T) {
// cache failure. A real xattr failure is still reported.
func TestRunIgnoresUnsupportedSignatureXattr(t *testing.T) {
// Simulate a filesystem without the managed namespace, regardless of this host's support.
previousRead, previousWrite := readManagedXattr, writeManagedXattr
previousRead, previousWrite, previousPath := readManagedXattr, writeManagedXattr, writeManagedPathXattr
t.Cleanup(func() {
readManagedXattr, writeManagedXattr = previousRead, previousWrite
readManagedXattr, writeManagedXattr, writeManagedPathXattr = previousRead, previousWrite, previousPath
})
readManagedXattr = func(*os.File) ([]byte, error) { return nil, errSignatureXattrUnsupported }
writeManagedXattr = func(*os.File, []byte) error { return errSignatureXattrUnsupported }
writeManagedPathXattr = func(string, []byte) error { return errSignatureXattrUnsupported }
// Keep an existing target so replacement also exercises the no-cache policy.
root := t.TempDir()
@@ -869,6 +883,7 @@ func TestRunIgnoresUnsupportedSignatureXattr(t *testing.T) {
// A broken attribute is still a failure, so the no-op policy cannot swallow real ones.
writeManagedXattr = func(*os.File, []byte) error { return errors.New("xattr is broken") }
writeManagedPathXattr = func(string, []byte) error { return errors.New("xattr is broken") }
if _, summary := run(); summary.Failures == 0 {
t.Fatalf("summary = %#v, want the broken attribute recorded", summary)
}
@@ -941,10 +956,9 @@ func TestRunReadsTheStoredHashThroughTheItemDescriptor(t *testing.T) {
}
}
// TestRunWritesTheComputedHashThroughTheItemDescriptor pins where a computed hash is published:
// through the descriptor that read the content, so the entry always describes the bytes this item
// read, before that descriptor closes.
func TestRunWritesTheComputedHashThroughTheItemDescriptor(t *testing.T) {
// TestRunWritesComputedCacheAfterSourceClose pins where a computed hash is published:
// through the stable source path after the content descriptor has closed.
func TestRunWritesComputedCacheAfterSourceClose(t *testing.T) {
for _, mode := range []ReadMode{ReadBuffered, ReadMapped} {
t.Run(mode.String(), func(t *testing.T) {
// A stored entry that differs from the computed hash requires one source publication.
@@ -953,8 +967,8 @@ func TestRunWritesTheComputedHashThroughTheItemDescriptor(t *testing.T) {
input := writeSourceFile(t, t.TempDir(), "source.txt", content)
seedStaleSignature(t, input, []byte("stale stored value"))
// Observe publication through the original descriptor while the file stays stable.
previousOpen, previousWrite := openSourceContent, writeManagedXattr
// Observe one content open and path publication after descriptor closure.
previousOpen, previousWrite := openSourceContent, writeManagedPathXattr
var opened *os.File
var opens, writes int
openSourceContent = func(path string, mode ReadMode, info os.FileInfo) (itemSource, error) {
@@ -963,14 +977,17 @@ func TestRunWritesTheComputedHashThroughTheItemDescriptor(t *testing.T) {
opened = source.file
return source, err
}
writeManagedXattr = func(file *os.File, value []byte) error {
writeManagedPathXattr = func(path string, value []byte) error {
writes++
if file != opened {
t.Errorf("cache write descriptor = %p, want the source descriptor %p", file, opened)
if path != input {
t.Errorf("cache path = %q, want %q", path, input)
}
return previousWrite(file, value)
if _, err := opened.Stat(); !errors.Is(err, os.ErrClosed) {
t.Errorf("cache publication preceded source Close: %v", err)
}
return previousWrite(path, value)
}
t.Cleanup(func() { openSourceContent, writeManagedXattr = previousOpen, previousWrite })
t.Cleanup(func() { openSourceContent, writeManagedPathXattr = previousOpen, previousWrite })
// Run a content refresh and retain the aggregate warning and write counts.
var summary SignatureCacheSummary
@@ -1120,7 +1137,7 @@ func TestRunPublishesTargetCacheThroughTheOpenTargetDescriptor(t *testing.T) {
// item computed from the whole source.
lock.Lock()
defer lock.Unlock()
if len(publications) != len(targets)+1 {
if len(publications) != len(targets) {
t.Fatalf("published %d cache entries, want one per file: %#v", len(publications), publications)
}
var sourcePublished, targetsPublished int
@@ -1151,8 +1168,8 @@ func TestRunPublishesTargetCacheThroughTheOpenTargetDescriptor(t *testing.T) {
}
targetsPublished++
}
if sourcePublished != 1 || targetsPublished != len(targets) {
t.Fatalf("publications = %#v, want one source entry and one per target", publications)
if sourcePublished != 0 || targetsPublished != len(targets) {
t.Fatalf("publications = %#v, want one descriptor publication per target", publications)
}
// Every entry describes its own file afterwards, which is the whole point of publishing it.
@@ -1172,8 +1189,7 @@ type blockingHash struct {
started chan struct{}
release chan struct{}
startOnce sync.Once
releaseOnce sync.Once
startOnce *sync.Once
}
func (h *blockingHash) Write(p []byte) (int, error) {
@@ -1189,28 +1205,18 @@ func (h *blockingHash) Write(p []byte) (int, error) {
func holdContentHash(t *testing.T) (started <-chan struct{}, released <-chan struct{}, release func()) {
t.Helper()
// Empty the shared pool, so the next hash consumer must take the blocked one.
// Every concurrent file owns its own digest; only the observation and release gates are shared.
previous := sha256Pool
previousNew := previous.New
previous.New = nil
for previous.Get() != nil {
}
previous.New = previousNew
first, gate := make(chan struct{}), make(chan struct{})
var startOnce sync.Once
sha256Pool = &sync.Pool{New: func() interface{} {
return &blockingHash{Hash: sha256.New(), started: first, release: gate, startOnce: &startOnce}
}}
blocked := &blockingHash{
Hash: sha256.New(),
started: make(chan struct{}),
release: make(chan struct{}),
}
sha256Pool = &sync.Pool{New: func() interface{} { return blocked }}
unblock := func() { blocked.releaseOnce.Do(func() { close(blocked.release) }) }
t.Cleanup(func() {
unblock()
sha256Pool = previous
})
return blocked.started, blocked.release, unblock
// Restore the original pool only after tests have joined their transfer workers.
unblock := onceRelease(gate)
t.Cleanup(func() { unblock(); sha256Pool = previous })
return first, gate, unblock
}
// TestRunHoldsTargetCacheUntilTheHashIsComplete pins the ordering a target's cache entry depends
+10
View File
@@ -44,6 +44,16 @@ func writeSignatureXattr(file *os.File, value []byte) error {
return err
}
func writeSignaturePathXattr(path string, value []byte) error {
// Keep the same user namespace and bounded encoded value as descriptor publication.
var data uintptr
if len(value) > 0 {
data = uintptr(unsafe.Pointer(&value[0]))
}
_, err := unix.ExtattrSetFile(path, unix.EXTATTR_NAMESPACE_USER, signatureXattrName, data, len(value))
return err
}
func isSignatureXattrMissing(err error) bool {
return errors.Is(err, unix.ENOATTR)
}
+4
View File
@@ -18,6 +18,10 @@ func writeSignatureXattr(*os.File, []byte) error {
return errSignatureXattrUnsupported
}
func writeSignaturePathXattr(string, []byte) error {
return errSignatureXattrUnsupported
}
func isSignatureXattrMissing(err error) bool {
return false
}
+4
View File
@@ -25,6 +25,10 @@ func writeSignatureXattr(file *os.File, value []byte) error {
return unix.Fsetxattr(int(file.Fd()), signatureXattrName, value, 0)
}
func writeSignaturePathXattr(path string, value []byte) error {
return unix.Setxattr(path, signatureXattrName, value, 0)
}
func isSignatureXattrMissing(err error) bool {
return isNoAttrErr(err)
}
+325
View File
@@ -0,0 +1,325 @@
package acp
import (
"bytes"
"errors"
"io"
"syscall"
"testing"
"testing/synctest"
"time"
)
func (m *ltfsModel) motion(state string, rate int64) {
// The physical server owns motion; snapshots retain each meaningful state transition.
m.lock.Lock()
defer m.lock.Unlock()
if m.state == state && len(m.motions) > 0 && m.motions[len(m.motions)-1].rate == rate {
return
}
m.state = state
m.signal()
m.motions = append(m.motions, tapeMotion{time.Since(m.started), state, rate})
}
func (m *ltfsModel) serveDrive() {
// Physical consumption uses buffered bytes and elapsed time; adaptive control changes only the bounded tape speed.
defer close(m.driveDone)
var deadline time.Time
streaming, ran := false, false
for {
m.lock.Lock()
for len(m.driveRequests) == 0 && !m.backendDone {
changed := m.changed
m.lock.Unlock()
<-changed
m.lock.Lock()
}
if len(m.driveRequests) == 0 {
m.lock.Unlock()
break
}
r := m.driveRequests[0]
m.driveRequests[0] = nil
m.driveRequests = m.driveRequests[1:]
position := m.position
m.lock.Unlock()
// A positioning command occupies the same physical device and never overlaps data service.
delay := time.Duration(0)
if r.position != position {
m.motion("locating", 0)
delay += m.config.seek
if m.config.seekCost != nil {
delay += m.config.seekCost(position, r.position)
}
m.seeks++
streaming = false
}
if !streaming && !r.read && m.config.resumeBytes > 0 {
m.motion("stopped", 0)
m.lock.Lock()
for m.drivePending < m.config.resumeBytes && !m.finalizing && m.failure == nil {
changed := m.changed
m.lock.Unlock()
<-changed
m.lock.Lock()
}
m.lock.Unlock()
}
if !ran {
delay += m.config.startup
}
if !streaming {
m.motion("starting", 0)
deadline = time.Now()
}
// One native byte is charged once; the configured rate never follows producer throughput.
rate := m.matchSpeed(time.Now())
m.motion("streaming", rate)
if rate > 0 {
delay += time.Duration(r.size * int64(time.Second) / rate)
}
m.lock.Lock()
failure := m.failure
m.lock.Unlock()
if m.config.service != nil {
failure = errors.Join(failure, m.config.service(*r))
}
if !r.read && m.config.capacity > 0 && r.position+r.size > m.config.capacity {
failure = errors.Join(failure, syscall.ENOSPC)
}
if r.read && r.queued.After(deadline) {
deadline = r.queued
}
deadline = deadline.Add(delay)
if wait := time.Until(deadline); wait > 0 {
time.Sleep(wait)
}
finished := time.Now()
m.lock.Lock()
m.busy += delay
m.overshoot = max(m.overshoot, max(time.Duration(0), finished.Sub(deadline)))
if failure == nil {
m.position = r.position + r.size
m.completed += r.size
if !r.read {
m.extents = append(m.extents, tapeExtent{r.path, r.position, r.size})
}
} else {
m.failure = failure
m.failedBytes += r.size
}
if !r.read {
m.drivePending -= r.size
}
streaming, ran = r.read || m.drivePending > 0, true
r.err = failure
m.curve = append(m.curve, tapeSample{host: m.pending, drive: m.drivePending, elapsed: time.Since(m.started), bytes: m.completed, position: m.position, pending: m.pending + m.drivePending})
starved := !r.read && m.drivePending == 0 && !m.finalizing && failure == nil && rate > 0
if starved {
m.backhitches++
m.state = "backhitch"
m.motions = append(m.motions, tapeMotion{time.Since(m.started), "backhitch", 0})
}
close(r.done)
m.signal()
m.lock.Unlock()
// Empty means stopped: reverse/reposition time lets the producer refill the independent buffer.
if starved {
if m.config.backhitch > 0 {
time.Sleep(m.config.backhitch)
}
m.lock.Lock()
m.busy += m.config.backhitch
m.state = "stopped"
m.signal()
m.lock.Unlock()
}
}
m.motion("stopped", 0)
}
func tapeProfile(stress bool) ltfsModelConfig {
// Native values come from the HP LTO5 family manual; hysteresis and motion costs are assumptions.
minimum := int64(47_000_000)
if stress {
minimum = 100_000_000
}
return ltfsModelConfig{adaptive: true, dispatchers: 1, bufferBytes: 1024 << 20, driveBufferBytes: 256_000_000, minimumRate: minimum, bytesPerSecond: 140_000_000, resumeBytes: 153_600_000, backhitch: 2500 * time.Millisecond}
}
func TestMockTapeCloseAcceptsButFinalizeDrains(t *testing.T) {
// A short file fits in the drive buffer without reaching the streaming restart watermark.
synctest.Test(t, func(t *testing.T) {
m := newLTFSModel(ltfsModelConfig{dispatchers: 1, bufferBytes: 4 << 20, driveBufferBytes: 4 << 20, minimumRate: 100_000_000, bytesPerSecond: 140_000_000, resumeBytes: 2 << 20})
out := &ltfsOutput{model: m, path: "file"}
if _, err := out.Write(make([]byte, 1<<20)); err != nil {
t.Fatal(err)
}
if err := out.Close(); err != nil {
t.Fatal(err)
}
synctest.Wait()
if m.completed != 0 || m.drivePending != 1<<20 || m.pending != 0 {
t.Fatalf("Close conflated acceptance and durability: completed=%d drive=%d host=%d", m.completed, m.drivePending, m.pending)
}
// Finalization permits the final tail and joins actual physical service exactly once.
if err := m.validate(); err != nil {
t.Fatal(err)
}
m.shutdown()
if m.accepted != 1<<20 || m.completed != 1<<20 || len(m.extents) != 1 || m.extents[0].position != 0 || m.backhitches != 0 {
t.Fatalf("tape accounting: %+v", m)
}
if m.busy < time.Duration((1<<20)*int64(time.Second)/140_000_000) {
t.Fatal("physical bytes escaped the speed ceiling")
}
})
}
func TestMockTapeSubMinimumSupplyCausesBackhitch(t *testing.T) {
// A 256 MB drive buffer absorbs temporary differences; sustained 80 MB/s eventually starves a fixed 100 MB/s consumer.
for _, stress := range []bool{false, true} {
t.Run(map[bool]string{false: "native47", true: "stress100"}[stress], func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
config := tapeProfile(stress)
config.adaptive = false // Fixed-rate control proves the byte ledger independently of speed matching.
config.bufferBytes = 1024 << 20
config.backhitch = 2500 * time.Millisecond // Explicit assumed reposition cost, independent of producer timing.
m := newLTFSModel(config)
out := &ltfsOutput{model: m, path: "stream"}
chunk := make([]byte, 256<<10)
for range 8192 {
if _, err := out.Write(chunk); err != nil {
t.Fatal(err)
}
time.Sleep(time.Duration(int64(len(chunk)) * int64(time.Second) / 80_000_000))
}
if err := out.Close(); err != nil {
t.Fatal(err)
}
if err := m.validate(); err != nil {
t.Fatal(err)
}
m.shutdown()
// A transient low feed sample is insufficient; actual buffer exhaustion drives the transition.
if stress && m.backhitches == 0 {
t.Fatal("100 MB/s floor did not expose sustained 80 MB/s supply")
}
if !stress && m.backhitches != 0 {
t.Fatalf("fixed 47 MB/s consumption caused %d unexpected backhitches", m.backhitches)
}
for _, motion := range m.motions {
if motion.state == "streaming" && (motion.rate < config.minimumRate || motion.rate > config.bytesPerSecond) {
t.Fatalf("invalid tape speed: %+v", motion)
}
}
t.Logf("minimum=%d MB/s accepted=%d backhitches=%d physical_busy=%s", config.minimumRate/1_000_000, m.accepted, m.backhitches, m.busy)
})
})
}
}
func TestMockTapeFiniteBuffersBackpressureWrite(t *testing.T) {
// Hold the physical drive while one MiB of drive space and two MiB of LTFS space fill.
synctest.Test(t, func(t *testing.T) {
gate := make(chan struct{})
m := newLTFSModel(ltfsModelConfig{bufferBytes: 2 << 20, driveBufferBytes: 1 << 20, service: func(tapeRequest) error { <-gate; return nil }})
out := &ltfsOutput{model: m, path: "bounded"}
written := make(chan struct{})
go func() { _, _ = out.Write(make([]byte, 4<<20)); close(written) }()
synctest.Wait()
select {
case <-written:
t.Fatal("Write exceeded finite combined caches")
default:
}
if m.accepted != 3<<20 || m.pending != 2<<20 || m.drivePending != 1<<20 {
t.Fatalf("host=%d drive=%d accepted=%d", m.pending, m.drivePending, m.accepted)
}
// A physical release creates space, and shutdown must conserve every accepted byte.
close(gate)
<-written
if err := out.Close(); err != nil {
t.Fatal(err)
}
if err := m.validate(); err != nil {
t.Fatal(err)
}
m.shutdown()
})
}
func TestMockTapeLocateAndEOM(t *testing.T) {
// Logical order alone does not give metres; a test supplies the cost of a noncontiguous transition.
synctest.Test(t, func(t *testing.T) {
m := newLTFSModel(ltfsModelConfig{bytesPerSecond: 140_000_000, seekCost: func(from, to int64) time.Duration {
if from != to {
return 50 * time.Second
}
return 0
}})
for _, position := range []int64{0, 1 << 20, 20 << 20} {
r := &ltfsReader{ReadCloser: io.NopCloser(bytes.NewReader(make([]byte, 1<<20))), model: m, path: "read", position: position}
if _, err := io.Copy(io.Discard, r); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
}
if err := m.validate(); err != nil {
t.Fatal(err)
}
m.shutdown()
if m.seeks != 1 || m.busy < 50*time.Second || m.backhitches != 0 {
t.Fatalf("seeks=%d backhitches=%d busy=%s", m.seeks, m.backhitches, m.busy)
}
})
// Device acceptance can precede a physical EOM; finalization must expose it with conserved failed bytes.
synctest.Test(t, func(t *testing.T) {
m := newLTFSModel(ltfsModelConfig{bufferBytes: 4 << 20, driveBufferBytes: 4 << 20, capacity: 1 << 20})
out := &ltfsOutput{model: m, path: "eom"}
for range 2 {
_, _ = out.Write(make([]byte, 1<<20))
}
_ = out.Close()
if err := m.finalize(); !errors.Is(err, syscall.ENOSPC) {
t.Fatalf("physical EOM=%v", err)
}
m.shutdown()
if m.accepted != m.completed+m.failedBytes || m.pending != 0 || m.drivePending != 0 {
t.Fatalf("EOM lost bytes: %+v", m)
}
})
}
func TestMockTapeRefillIsNotLimitedByTicketQueue(t *testing.T) {
// More than 1024 small backend commands may be needed to fill a byte-based restart watermark.
synctest.Test(t, func(t *testing.T) {
m := newLTFSModel(ltfsModelConfig{bufferBytes: 1 << 20, driveBufferBytes: 256 << 10, resumeBytes: 160 << 10, minimumRate: 100_000_000, bytesPerSecond: 140_000_000})
out := &ltfsOutput{model: m, path: "small-records"}
for range 1500 {
if _, err := out.Write(make([]byte, 128)); err != nil {
t.Fatal(err)
}
}
if err := out.Close(); err != nil {
t.Fatal(err)
}
if err := m.validate(); err != nil {
t.Fatal(err)
}
m.shutdown()
if m.completed != 1500*128 {
t.Fatalf("drained=%d", m.completed)
}
})
}
+139
View File
@@ -0,0 +1,139 @@
package acp
import (
"math"
"os"
"testing"
"testing/synctest"
"time"
)
// speedMatching is a declared simulation controller, not a reconstruction of HP firmware.
// Its input is actual drive-buffer admission; it cannot manufacture bytes or suppress starvation.
type speedMatching struct {
last time.Time
accepted int64
feed, rate float64
}
type speedSample struct {
elapsed time.Duration
buffer int64
feed, target, rate float64
}
func (m *ltfsModel) matchSpeed(now time.Time) int64 {
// Fixed-speed controls exercise the same physical byte ledger without adaptation.
if !m.config.adaptive {
return m.config.streamRate
}
m.lock.Lock()
defer m.lock.Unlock()
s := &m.matching
dt := now.Sub(s.last)
if dt < m.config.controlPeriod {
return int64(s.rate)
}
// Smooth actual native-byte arrivals independently of file, hash, Write and Close boundaries.
incoming := float64(m.driverAccepted-s.accepted) / dt.Seconds()
weight := dt.Seconds() / (m.config.smoothing.Seconds() + dt.Seconds())
s.feed += (incoming - s.feed) * weight
target := s.feed + (float64(m.drivePending)-m.config.targetFill*float64(m.config.driveBufferBytes))/m.config.correction.Seconds()
target = min(float64(m.config.bytesPerSecond), max(float64(m.config.minimumRate), target))
// Bound acceleration rather than instantly mirroring a host burst; the physical consumer remains independent.
change := float64(m.config.slewPerSecond) * dt.Seconds()
if math.Abs(target-s.rate) >= 1_000_000 {
s.rate += min(change, max(-change, target-s.rate))
}
s.rate = min(float64(m.config.bytesPerSecond), max(float64(m.config.minimumRate), s.rate))
s.last, s.accepted = now, m.driverAccepted
m.controls = append(m.controls, speedSample{time.Since(m.started), m.drivePending, s.feed, target, s.rate})
return int64(s.rate)
}
func TestMockTapeAdaptiveSpeedTracksActualBufferSupply(t *testing.T) {
// A full-size drive buffer must support changing in-range supply without synthetic throughput-based stops.
synctest.Test(t, func(t *testing.T) {
m := newLTFSModel(tapeProfile(true))
out := &ltfsOutput{model: m, path: "adaptive"}
chunk := make([]byte, 1<<20)
for _, offered := range []int64{120_000_000, 135_000_000, 110_000_000} {
for range 1536 {
if _, err := out.Write(chunk); err != nil {
t.Fatal(err)
}
time.Sleep(time.Duration(int64(len(chunk)) * int64(time.Second) / offered))
}
// The end of each sustained phase must track its input, not merely change speed during startup.
m.lock.Lock()
cutoff := time.Since(m.started) - 3*time.Second
var count int
var sum float64
for _, sample := range m.controls {
if sample.elapsed >= cutoff {
sum += sample.rate
count++
}
}
m.lock.Unlock()
if count < 10 || math.Abs(sum/float64(count)-float64(offered)) > 5_000_000 {
t.Errorf("speed did not settle near supply: offered=%d samples=%d mean=%f", offered, count, sum/float64(count))
}
}
if err := out.Close(); err != nil {
t.Fatal(err)
}
if err := m.validate(); err != nil {
t.Fatal(err)
}
m.shutdown()
// The controller really changes speed, remains bounded and preserves continuous tape motion.
if m.backhitches != 0 {
t.Fatalf("in-range supply caused %d backhitches", m.backhitches)
}
low, high := float64(140_000_000), float64(100_000_000)
for _, s := range m.controls {
if s.rate < 100_000_000 || s.rate > 140_000_000 {
t.Fatalf("speed outside stress range: %+v", s)
}
low, high = min(low, s.rate), max(high, s.rate)
}
if high-low < 15_000_000 {
t.Fatalf("speed did not adapt meaningfully: min=%f max=%f", low, high)
}
// Opt-in traces expose the same deterministic controller checked above.
if directory := os.Getenv("ACP_MOCK_REPORT_DIR"); directory != "" {
writeTapeModelReport(t, directory, m)
}
})
}
func TestMockTapeAdaptiveSpeedStillStarvesBelowMinimum(t *testing.T) {
// Adaptive control can reduce speed to 100 MB/s but cannot compensate for a persistent 80 MB/s feed.
synctest.Test(t, func(t *testing.T) {
m := newLTFSModel(tapeProfile(true))
out := &ltfsOutput{model: m, path: "underfed"}
chunk := make([]byte, 1<<20)
for range 2048 {
if _, err := out.Write(chunk); err != nil {
t.Fatal(err)
}
time.Sleep(time.Duration(int64(len(chunk)) * int64(time.Second) / 80_000_000))
}
if err := out.Close(); err != nil {
t.Fatal(err)
}
if err := m.validate(); err != nil {
t.Fatal(err)
}
m.shutdown()
if m.backhitches == 0 {
t.Fatal("adaptive controller hid actual drive-buffer exhaustion")
}
})
}
+24 -78
View File
@@ -37,7 +37,7 @@ func (c *StreamCopyer) indexTarget(name string, source os.FileInfo) (targetSpec,
}
if info != nil && info.Mode()&os.ModeSymlink != 0 {
// An existing link keeps its identity: only its live referent can be replaced.
target.path, err = filepath.EvalSymlinks(name)
target.path, err = resolveOutputPath(name)
if err != nil {
return target, fmt.Errorf("resolve target link failed, %w", err)
}
@@ -68,6 +68,8 @@ func (c *StreamCopyer) indexTarget(name string, source os.FileInfo) (targetSpec,
}
target.path = filepath.Join(dir, base)
}
// Refuse source aliases and unsupported target types before any output is created.
if info != nil {
if source != nil && os.SameFile(source, info) {
return target, fmt.Errorf("source and target are the same file")
@@ -80,6 +82,8 @@ func (c *StreamCopyer) indexTarget(name string, source os.FileInfo) (targetSpec,
return target, fmt.Errorf("target is not a regular file or device")
}
}
// Retain the mount observation alongside the canonical destination identity.
target.device, err = c.getDevice(target.path)
if err != nil {
return target, fmt.Errorf("get target device failed, %w", err)
@@ -112,6 +116,22 @@ func resolveOutputPath(path string) (string, error) {
path = parent
}
// Anchor relative paths without cleaning away link/.. before the symlink walk.
// Windows drive-relative paths use that drive's cwd; root-relative paths use its volume.
if !filepath.IsAbs(path) {
volume := filepath.VolumeName(path)
base, err := filepath.Abs(volume + ".")
if err != nil {
return "", err
}
path = path[len(volume):]
if len(path) > 0 && os.IsPathSeparator(path[0]) {
path = filepath.VolumeName(base) + path
} else {
path = base + string(filepath.Separator) + path
}
}
// Resolve once, then retain missing descendants beneath that physical directory.
resolved, err := resolveDirectory(path)
if err != nil {
@@ -124,15 +144,11 @@ func resolveOutputPath(path string) (string, error) {
}
func (c *StreamCopyer) prepareTarget(job *writeJob, target targetSpec) (out *fileio.Output, err error) {
// Linear media cannot preallocate. Check their current free space immediately before writing.
if c.toDevice.linear && job.stat.size > 0 {
available, err := c.availableSpace(target.device)
if err != nil {
// Linear media cannot preallocate; reject insufficient estimated space before output creation.
if c.toDevice.linear {
if err := c.checkLinearSpace(job, target); err != nil {
return nil, err
}
if job.stat.size > available {
return nil, fmt.Errorf("%w, want=%d have=%d", ErrTargetNoSpace, job.stat.size, available)
}
}
// Rewrite has already allocated and recorded its output; ordinary files allocate here.
@@ -184,76 +200,6 @@ func (c *StreamCopyer) targetFailed(job *baseJob, path, dev string, err error, e
job.fail(path, err)
}
func (c *StreamCopyer) consumeTarget(job *writeJob, target targetSpec, out *fileio.Output, chunks <-chan *chunkBuffer,
cacheGate <-chan struct{}, readErr *error, exhausted mapset.Set[string]) {
// The output owns its descriptor and temporary until commit or cleanup. Every failure is
// reported against the requested path, and every queued buffer is released.
var err error
defer func() {
if value := recover(); value != nil {
err = panicError("target writer", value)
c.setError(err)
c.stopHard()
}
err = errors.Join(err, out.Discard())
if err != nil {
c.targetFailed(job.baseJob, target.name, target.device, err, exhausted)
} else {
job.success(target.name)
}
for chunk := range chunks {
chunk.release()
}
}()
// Write each source chunk once before completing metadata and the content signature.
for chunk := range chunks {
if err = writeChunk(out.File, chunk); err != nil {
return
}
}
if *readErr != nil {
err = *readErr
return
}
if cacheGate != nil {
<-cacheGate
}
select {
case <-c.hardStop:
err = fmt.Errorf("target stopped by pipeline failure")
return
default:
}
if !target.direct {
// Publish the managed attribute while the owned temporary is still writable.
c.refreshCacheEntry(out.File, target.name, job.baseJob, true)
if err = restoreTarget(out.Temporary, job.stat); err != nil {
err = fmt.Errorf("restore target metadata failed, %w", err)
return
}
}
if !c.toDevice.linear {
if err = out.File.Sync(); err != nil {
err = fmt.Errorf("sync target failed, %w", err)
return
}
}
// Rewrite replaces its own source. Release its descriptor and mapping before rename.
if target.output != nil {
if err = job.finishSource(); err != nil {
err = fmt.Errorf("close rewrite source failed, %w", err)
return
}
}
// Only a complete, closed output replaces the final path; failure leaves the old file intact.
if err = commitTarget(out, c.createFlag&os.O_TRUNC != 0); err != nil {
err = fmt.Errorf("commit target failed, %w", err)
}
}
func writeChunk(writer io.Writer, chunk *chunkBuffer) error {
// The writer releases its reference even on panic or a short write.
defer chunk.release()
+163
View File
@@ -0,0 +1,163 @@
package acp
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"testing/synctest"
)
type delayedPublicationOutput struct {
transferOutput
gate <-chan struct{}
}
func (o *delayedPublicationOutput) Commit(overwrite bool) error {
// Hold final replacement while another file is free to complete its own data and metadata.
<-o.gate
return o.transferOutput.Commit(overwrite)
}
func TestRelativeAndAbsoluteTargetsKeepSerialWinner(t *testing.T) {
// Both device modes formerly finished each file before admitting the next writer.
for _, config := range []struct {
name string
linear bool
linked bool
}{
{"random", false, false},
{"linear", true, false},
{"random-linked-cwd", false, true},
{"linear-linked-cwd", true, true},
} {
t.Run(config.name, func(t *testing.T) {
for _, overwrite := range []bool{false, true} {
t.Run(map[bool]string{false: "exclusive", true: "overwrite"}[overwrite], func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Index both names of one stable destination before either output is created.
c := newTestStream(t, Overwrite(overwrite), WithHashPolicy(HashOff),
SetFromDevice(DeviceThreads(1)), SetToDevice(DeviceThreads(1), LinearDevice(config.linear)))
root := t.TempDir()
cwd := root
if config.linked {
cwd = filepath.Join(root, "alias")
if err := os.Symlink(root, cwd); err != nil {
t.Skipf("symlinks unsupported: %v", err)
}
}
t.Chdir(cwd)
absolute := filepath.Join(root, "target")
if overwrite {
writeSourceFile(t, root, "target", []byte("original"))
}
names := []string{"target", absolute}
prepared := make(chan *writeJob, 2)
for i, name := range names {
content := []string{"first", "second"}[i]
source := writeSourceFile(t, root, content, []byte(content))
job := c.buildJob(&SimpleJob{Path: source, Dsts: []string{name}}, uint64(i))
if job.itemError != nil || len(job.outputs) != 1 {
t.Fatalf("index outcome: %+v", job.result())
}
prepared <- c.prepareItem(job)
}
close(prepared)
// Delay the first native replacement so an unrelated publication could overtake it.
gate := make(chan struct{})
native := c.fs()
c.filesystem = pipelineFilesystem{
transferFilesystem: native,
create: func(job *writeJob, spec targetSpec) (transferOutput, error) {
// Retain the real filesystem lifecycle while holding only the first publication.
out, err := native.Create(job, spec)
if err != nil {
return nil, err
}
if job.order != 0 {
return out, nil
}
return &delayedPublicationOutput{transferOutput: out, gate: gate}, nil
},
}
out := c.copy(context.Background(), prepared)
synctest.Wait()
close(gate)
// Keep the serial winner and each caller's original name in the actual outcomes.
count := 0
for job := range out {
count++
result := job.result()
if result.Err != nil || len(result.Targets) != 1 || result.Targets[0].Path != names[job.order] {
t.Fatalf("result lost its logical target: %+v", result)
}
err := result.Targets[0].Err
if !overwrite && job.order == 1 {
if !errors.Is(err, os.ErrExist) {
t.Errorf("later exclusive output error=%v, want existence failure", err)
}
continue
}
if err != nil {
t.Errorf("order=%d failed: %v", job.order, err)
}
}
want := "first"
if overwrite {
want = "second"
}
data, err := os.ReadFile(absolute)
if err != nil || string(data) != want || count != 2 {
t.Fatalf("final target=%q, want %q; results=%d: %v", data, want, count, err)
}
})
})
}
})
}
}
func TestTargetParentTraversalFromSymlinkedWorkingDirectory(t *testing.T) {
// Both ordinary targets and existing target links follow the physical parent of cwd.
for _, name := range []string{"../target", "../link"} {
t.Run(name, func(t *testing.T) {
// The logical parent holds an unrelated file that replacement must never touch.
root := t.TempDir()
parent := filepath.Join(root, "tree")
child := filepath.Join(parent, "child")
if err := os.MkdirAll(child, 0o755); err != nil {
t.Fatal(err)
}
alias := filepath.Join(root, "alias")
if err := os.Symlink(child, alias); err != nil {
t.Skipf("symlinks unsupported: %v", err)
}
t.Chdir(alias)
source := writeSourceFile(t, root, "source", []byte("new content"))
target := writeSourceFile(t, parent, "target", []byte("original target"))
decoy := writeSourceFile(t, root, "target", []byte("unrelated content"))
if err := os.Symlink("target", filepath.Join(parent, "link")); err != nil {
t.Fatal(err)
}
// Native copying must use the resolved identity while reporting the caller's relative name.
item := newFixtureItem(source, name)
if err := runFixture(context.Background(), newStreamFixture(item), []Item{item}, Overwrite(true)); err != nil {
t.Fatal(err)
}
result, err := item.terminal(t)
if err != nil || len(result.Targets) != 1 || result.Targets[0].Path != name || result.Targets[0].Err != nil {
t.Fatalf("copy result: %+v / %v", result, err)
}
for path, want := range map[string]string{target: "new content", decoy: "unrelated content"} {
data, err := os.ReadFile(path)
if err != nil || string(data) != want {
t.Errorf("path=%q content=%q, want %q: %v", path, data, want, err)
}
}
})
}
}
+80 -4
View File
@@ -12,6 +12,82 @@ import (
"time"
)
func TestLinearTargetCapacityEstimateBoundaries(t *testing.T) {
observationErr := errors.New("capacity observation failed")
for _, tt := range []struct {
name string
content string
available int64
queryErr error
wantCalls int
}{
{name: "equal capacity", content: "data", available: 4, wantCalls: 1},
{name: "empty file", queryErr: observationErr},
{name: "observation failure", content: "data", queryErr: observationErr, wantCalls: 1},
} {
t.Run(tt.name, func(t *testing.T) {
// Observe capacity before creating even the target directory, without allocating large fixtures.
root := t.TempDir()
source := writeSourceFile(t, root, "source", []byte(tt.content))
parent := filepath.Join(root, "destination")
target := filepath.Join(parent, "target")
item := newFixtureItem(source, target)
copyer, err := NewStream(context.Background(), newStreamFixture(item).onResults,
SetToDevice(LinearDevice(true)))
if err != nil {
t.Fatal(err)
}
copyer.getDevice = func(string) (string, error) { return root, nil }
calls := 0
copyer.availableSpace = func(device string) (int64, error) {
calls++
if device != root {
t.Errorf("capacity device = %q, want %q", device, root)
}
if _, err := os.Stat(parent); !errors.Is(err, os.ErrNotExist) {
t.Errorf("target directory exists before capacity observation: %v", err)
}
return tt.available, tt.queryErr
}
// Use real source metadata so permitted writes complete through the ordinary target lifecycle.
if err := copyer.Submit(item); err != nil {
t.Error(err)
}
if err := copyer.Close(); err != nil {
t.Fatal(err)
}
if err := copyer.Wait(); err != nil {
t.Fatal(err)
}
// Equal capacity and empty files complete; observation errors retain their original identity.
result, err := item.terminal(t)
if err != nil || len(result.Targets) != 1 || result.Targets[0].Path != target {
t.Fatalf("unexpected result: %+v / %v", result, err)
}
if calls != tt.wantCalls || copyer.linearTargetStopped() {
t.Fatalf("capacity calls = %d, want %d; stopped = %t", calls, tt.wantCalls, copyer.linearTargetStopped())
}
if tt.queryErr != nil && tt.wantCalls > 0 {
if !errors.Is(result.Targets[0].Err, observationErr) || errors.Is(result.Targets[0].Err, ErrTargetNoSpace) {
t.Fatalf("observation error = %v", result.Targets[0].Err)
}
if _, err := os.Stat(parent); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("observation failure created output directory: %v", err)
}
return
}
if result.Targets[0].Err != nil {
t.Fatal(result.Targets[0].Err)
}
if content, err := os.ReadFile(target); err != nil || string(content) != tt.content {
t.Fatalf("target content = %q / %v, want %q", content, err, tt.content)
}
})
}
}
func TestCopyRejectsSourceAliasesBeforeModification(t *testing.T) {
for _, mode := range []ReadMode{ReadBuffered, ReadMapped} {
for _, alias := range []string{"same path", "hardlink", "symlink"} {
@@ -150,7 +226,7 @@ func TestSourceDescriptorClosesWhenCacheOperationPanics(t *testing.T) {
// Inject a panic after the item has acquired its real source descriptor.
root := t.TempDir()
source := writeSourceFile(t, root, "source", []byte("cache panic fixture"))
oldRead, oldWrite := readManagedXattr, writeManagedXattr
oldRead, oldWrite := readManagedXattr, writeManagedPathXattr
sentinel := errors.New("cache operation panic")
var opened *os.File
policy := HashCachedOnly
@@ -158,10 +234,10 @@ func TestSourceDescriptorClosesWhenCacheOperationPanics(t *testing.T) {
readManagedXattr = func(file *os.File) ([]byte, error) { opened = file; panic(sentinel) }
} else {
policy = HashReadRefresh
readManagedXattr = func(file *os.File) ([]byte, error) { return nil, errSignatureXattrUnsupported }
writeManagedXattr = func(file *os.File, value []byte) error { opened = file; panic(sentinel) }
readManagedXattr = func(file *os.File) ([]byte, error) { opened = file; return nil, errSignatureXattrUnsupported }
writeManagedPathXattr = func(string, []byte) error { panic(sentinel) }
}
t.Cleanup(func() { readManagedXattr, writeManagedXattr = oldRead, oldWrite })
t.Cleanup(func() { readManagedXattr, writeManagedPathXattr = oldRead, oldWrite })
item := newFixtureItem(source)
err := runFixture(context.Background(), newStreamFixture(item), []Item{item}, WithHashPolicy(policy))
if !errors.Is(err, sentinel) {
+226
View File
@@ -0,0 +1,226 @@
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{}
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,
}
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
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 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 {
select {
case <-t.stop:
return true
case <-t.copyer.hardStop:
return true
default:
return false
}
}
func (t *transfer) stoppedError() error { return fmt.Errorf("source prefetch stopped") }
+104
View File
@@ -0,0 +1,104 @@
package acp
import (
"context"
"errors"
"fmt"
"io"
)
func (t *transfer) readContent() {
// Every exit closes both data channels, releases the source turn and starts one source Close.
defer func() {
if value := recover(); value != nil {
t.readErr = t.panicFailure("source reader", value)
}
close(t.chunks)
if t.hashes != nil {
close(t.hashes)
}
t.ahead.finishRead(t.job.order)
close(t.readDone)
go t.copyer.wrap(context.Background(), func() {
defer close(t.sourceDone)
defer func() {
if value := recover(); value != nil {
t.sourceErr = t.panicFailure("source close", value)
}
}()
t.sourceErr = t.job.finishSource()
})
}()
if !t.wait(t.readTurn) || t.noContent {
return
}
// Quota-limited short blocks are not EOF; only the actual reader outcome ends content.
var offset int64
for !t.stopped() {
eof := t.job.stat != nil && offset >= t.job.stat.size
want, err := t.ahead.wait(t.job.order, t.start+offset, batchSize, eof, t.stop)
if err != nil {
t.readErr = err
return
}
if t.stopped() {
break
}
n, done, err := t.readChunk(want)
offset += int64(n)
if err != nil {
t.readErr = err
return
}
if done {
return
}
}
t.readErr = t.stoppedError()
}
func (t *transfer) readChunk(want int) (int, bool, error) {
// Backing ownership is established before reading; no read slot is held during queue waits.
chunk, err := t.ahead.acquire(t.job.order, t.stop)
if err != nil {
return 0, false, err
}
defer chunk.release()
release, err := t.ahead.readSlot(t.job.order, t.stop)
if err != nil {
return 0, false, err
}
var n int
func() {
defer release()
n, err = io.ReadFull(t.job.reader, chunk.data[:want])
}()
done := errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)
if err != nil && !done {
return 0, false, fmt.Errorf("read source failed, %w", err)
}
if n == 0 {
return 0, done, nil
}
// Hash and target consumers own independent references to the same immutable bytes.
chunk.data = chunk.data[:n]
consumers := [2]chan *chunkBuffer{t.chunks, t.hashes}
for _, dst := range consumers {
if dst == nil {
continue
}
reference := chunk.retain()
select {
case dst <- reference:
case <-t.stop:
reference.release()
return n, false, t.stoppedError()
case <-t.copyer.hardStop:
reference.release()
return n, false, t.stoppedError()
}
}
return n, done, nil
}
+293
View File
@@ -0,0 +1,293 @@
package acp
import (
"context"
"errors"
"fmt"
"os"
"sync"
"sync/atomic"
mapset "github.com/deckarep/golang-set/v2"
)
type publicationOrder struct {
lock sync.Mutex
pending map[string]chan struct{}
}
type publicationTurn struct {
previous <-chan struct{}
directTurn <-chan struct{}
done chan struct{}
release func()
}
func newPublicationOrder() *publicationOrder {
return &publicationOrder{pending: make(map[string]chan struct{})}
}
func (p *publicationOrder) reserve(path string) publicationTurn {
// Only conflicting paths share a completion dependency; finished entries do not accumulate.
p.lock.Lock()
defer p.lock.Unlock()
done := make(chan struct{})
turn := publicationTurn{previous: p.pending[path], done: done}
p.pending[path] = done
turn.release = func() {
// A failed middle output must retain its predecessor's publication dependency.
if turn.previous != nil {
<-turn.previous
}
// Completion removes only its own latest ticket, keeping path history bounded.
p.lock.Lock()
defer p.lock.Unlock()
close(done)
if p.pending[path] == done {
delete(p.pending, path)
}
}
return turn
}
func (t *transfer) reservePublications(order *publicationOrder) {
// Formerly serialized files keep their conflicting-output winners across asynchronous completion.
c := t.copyer
if t.noContent || (!c.toDevice.linear && c.toDevice.threads != 1 && !c.fromDevice.linear) {
return
}
t.publications = make([]publicationTurn, len(t.job.outputs))
for i, spec := range t.job.outputs {
turn := order.reserve(spec.path)
if spec.direct {
// Aliases in one file share only the prior file's device lifetime, never their own data gate.
turn.directTurn = turn.previous
for j := 0; j < i; j++ {
if t.job.outputs[j].path == spec.path {
turn.directTurn = t.publications[j].directTurn
break
}
}
}
t.publications[i] = turn
}
}
type targetTransfer struct {
spec targetSpec
output transferOutput
chunks chan *chunkBuffer
err error
publication publicationTurn
}
func (t *transfer) writeContent(cntr *counter, exhausted mapset.Set[string]) {
// Data consumers settle before ownership moves to independent completion workers.
c := t.copyer
var targets []*targetTransfer
var data sync.WaitGroup
defer func() {
var failure error
if value := recover(); value != nil {
failure = t.panicFailure("target data", value)
}
for _, target := range targets {
if target.chunks != nil {
close(target.chunks)
}
}
data.Wait()
<-t.readDone
for _, target := range targets {
target.err = errors.Join(target.err, t.readErr, failure)
t.targets.Add(1)
go c.wrap(context.Background(), func() {
defer t.targets.Done()
t.finishTarget(target, exhausted)
})
}
if t.readErr != nil && len(targets) == 0 && t.hashes != nil {
t.job.itemError = t.readErr
}
}()
// Prepare each independent outcome before transferring output ownership to its consumer.
for i, spec := range t.job.outputs {
// A direct device becomes visible during Write and may require the preceding handle to close.
if i < len(t.publications) && !t.wait(t.publications[i].directTurn) {
t.job.fail(spec.name, fmt.Errorf("direct target stopped by pipeline failure"))
continue
}
// A preceding Close may have exhausted this device while its handle was still owned.
if exhausted.Contains(spec.device) {
t.job.fail(spec.name, ErrTargetNoSpace)
continue
}
output, err := c.fs().Create(t.job, spec)
if err != nil {
c.targetFailed(t.job.baseJob, spec.name, spec.device, err, exhausted)
continue
}
var turn publicationTurn
if i < len(t.publications) {
turn = t.publications[i]
}
target := &targetTransfer{spec: spec, output: output, publication: turn}
targets = append(targets, target)
if i < len(t.publications) {
t.publications[i] = publicationTurn{}
}
if !c.toDevice.linear {
target.chunks = make(chan *chunkBuffer, 4)
data.Add(1)
go c.wrap(context.Background(), func() {
defer data.Done()
t.writeTargetData(target)
})
}
}
// Failed consumers keep draining; only an item with no content consumer stops reading early.
if len(targets) == 0 && t.hashes == nil {
t.abort()
}
var consumed int64
for chunk := range t.chunks {
continued := func() bool {
defer chunk.release()
if len(targets) == 0 && t.hashes == nil {
return true
}
for _, target := range targets {
if c.toDevice.linear {
// All linear outputs share this one application data writer.
if target.err == nil {
if err := writeChunk(target.output, chunk.retain()); err != nil {
target.err = err
c.endLinearTarget(err)
}
}
continue
}
reference := chunk.retain()
select {
case target.chunks <- reference:
case <-c.hardStop:
reference.release()
return false
}
}
consumed += int64(len(chunk.data))
atomic.AddInt64(&cntr.bytes, int64(len(chunk.data)))
t.ahead.advance(t.job.order, t.start+consumed)
return true
}()
if !continued {
return
}
}
}
func (t *transfer) writeTargetData(target *targetTransfer) {
// Each random output owns its errors and drains every reference after failure or panic.
defer func() {
if value := recover(); value != nil {
target.err = errors.Join(target.err, t.panicFailure("target data consumer", value))
}
for chunk := range target.chunks {
chunk.release()
}
}()
for chunk := range target.chunks {
if target.err != nil {
chunk.release()
continue
}
if err := writeChunk(target.output, chunk); err != nil {
target.err = err
t.copyer.endLinearTarget(err)
}
}
}
func (t *transfer) finishTarget(target *targetTransfer, exhausted mapset.Set[string]) {
// Publication release survives a secondary panic while cleanup reports its outcome.
if target.publication.release != nil {
defer target.publication.release()
}
// One completion owner closes/discards the output and reports its terminal outcome.
c, out := t.copyer, target.output
defer func() {
if value := recover(); value != nil {
target.err = errors.Join(target.err, t.panicFailure("target completion", value))
}
cleanup := protectCall("discard target", func() { target.err = errors.Join(target.err, out.Discard()) })
if cleanup != nil {
target.err = errors.Join(target.err, cleanup)
c.setError(cleanup)
c.stopHard()
}
if target.err != nil {
c.targetFailed(t.job.baseJob, target.spec.name, target.spec.device, target.err, exhausted)
} else {
t.job.success(target.spec.name)
}
}()
// Linear Close is independent of hash and the next data producer, and its error is authoritative.
if c.toDevice.linear {
if err := out.Close(); err != nil {
target.err = errors.Join(target.err, fmt.Errorf("close target failed, %w", err))
c.endLinearTarget(target.err)
}
}
if target.err != nil {
return
}
t.publishHash()
if t.hashErr != nil {
target.err = t.hashErr
return
}
select {
case <-c.hardStop:
target.err = fmt.Errorf("target stopped by pipeline failure")
return
default:
}
// Managed metadata precedes restrictive permissions; ordinary random targets retain their sync order.
if !target.spec.direct {
out.Cache(t.job.baseJob, c.toDevice.linear)
if err := out.Restore(t.job.stat); err != nil {
target.err = fmt.Errorf("restore target metadata failed, %w", err)
c.endLinearTarget(target.err)
return
}
}
if !c.toDevice.linear {
if err := out.Sync(); err != nil {
target.err = fmt.Errorf("sync target failed, %w", err)
return
}
}
// Rewrite cannot replace the source path before its independent cache and source Close have settled.
if target.spec.output != nil {
<-t.sourceCacheDone
if t.sourceErr != nil {
target.err = fmt.Errorf("close rewrite source failed, %w", t.sourceErr)
return
}
}
if !t.wait(target.publication.previous) {
target.err = fmt.Errorf("publication stopped by pipeline failure")
return
}
if err := out.Commit(c.createFlag&os.O_TRUNC != 0); err != nil {
target.err = fmt.Errorf("commit target failed, %w", err)
c.endLinearTarget(target.err)
}
}
+34 -7
View File
@@ -12,14 +12,24 @@ import (
)
func BenchmarkCopyWorkload(b *testing.B) {
// Keep disk regressions alongside YATM-shaped linear workloads in one shared harness.
for _, fixture := range []struct {
name string
files, size, targets int
linear bool
mode ReadMode
mixed bool
}{
{"LargeOneTarget", 1, 64 << 20, 1},
{"LargeThreeTargets", 1, 64 << 20, 3},
{"SmallOneTarget", 512, 4096, 1},
{"SmallThreeTargets", 512, 4096, 3},
{"LargeOneTarget", 1, 64 << 20, 1, false, ReadBuffered, false},
{"LargeThreeTargets", 1, 64 << 20, 3, false, ReadBuffered, false},
{"SmallOneTarget", 512, 4096, 1, false, ReadBuffered, false},
{"SmallThreeTargets", 512, 4096, 3, false, ReadBuffered, false},
{"LinearSmallBuffered", 256, 256 << 10, 1, true, ReadBuffered, false},
{"LinearSmallMapped", 256, 256 << 10, 1, true, ReadMapped, false},
{"LinearLargeBuffered", 1, 64 << 20, 1, true, ReadBuffered, false},
{"LinearLargeMapped", 1, 64 << 20, 1, true, ReadMapped, false},
{"LinearMixedBuffered", 132, 8 << 20, 1, true, ReadBuffered, true},
{"LinearMixedMapped", 132, 8 << 20, 1, true, ReadMapped, true},
} {
b.Run(fixture.name, func(b *testing.B) {
// Build identical real-file fixtures outside the timer; all destinations use the same filesystem.
@@ -29,15 +39,28 @@ func BenchmarkCopyWorkload(b *testing.B) {
for index := range content {
content[index] = byte(index*31 + 7)
}
var total int64
for index := range sources {
sources[index] = filepath.Join(root, fmt.Sprintf("source-%04d", index))
if err := os.WriteFile(sources[index], content, 0o600); err != nil {
size := fixture.size
if fixture.mixed && index%33 != 32 {
size = 256 << 10
}
total += int64(size)
if err := os.WriteFile(sources[index], content[:size], 0o600); err != nil {
b.Fatal(err)
}
}
// Match Archive hashing and target scheduling without changing ordinary disk cases.
logger := logrus.New()
logger.SetOutput(io.Discard)
b.SetBytes(int64(fixture.files) * int64(fixture.size))
options := []Option{WithHashPolicy(HashRead), WithLogger(logger)}
if fixture.linear {
options = []Option{WithHashPolicy(HashReadRefresh), WithLogger(logger),
SetToDevice(LinearDevice(true)), SetFromDevice(WithReadMode(fixture.mode))}
}
b.SetBytes(total)
b.ResetTimer()
for iteration := 0; iteration < b.N; iteration++ {
// Allocate a fresh destination tree, keeping fixture setup and deletion outside the measurement.
@@ -54,6 +77,8 @@ func BenchmarkCopyWorkload(b *testing.B) {
}
items = append(items, &SimpleJob{Path: source, Dsts: targets})
}
// Fail measured runs on any item or target error.
completed := 0
onResults := func(results []Result) error {
for _, result := range results {
@@ -72,8 +97,10 @@ func BenchmarkCopyWorkload(b *testing.B) {
// Measure the whole copy lifecycle, including hashing, sync, metadata and result delivery.
b.StartTimer()
err = runStream(context.Background(), onResults, items, WithHashPolicy(HashRead), WithLogger(logger))
err = runStream(context.Background(), onResults, items, options...)
b.StopTimer()
// Remove only this iteration's destinations after checking complete delivery.
if err != nil || completed != fixture.files {
b.Fatalf("copy: completed=%d err=%v", completed, err)
}