mirror of
https://github.com/samuelncui/acp.git
synced 2026-11-08 04:21:16 +00:00
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:
1 parent
4d5dbd0b8c
commit
159d8ea4ea
42 files changed
+4996
-574
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 := <fsModel{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 = <fsReader{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 := <fsOutput{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
|
||||
}
|
||||
@@ -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 := <fsOutput{model: m, path: "a"}, <fsOutput{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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 := <fsOutput{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 := <fsOutput{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 := <fsOutput{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 := <fsReader{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 := <fsOutput{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 := <fsOutput{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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 := <fsOutput{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 := <fsOutput{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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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") }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user