diff --git a/README.md b/README.md index 2310a65..31106e6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/TESTING.md b/TESTING.md index e2116c5..445b0fa 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 --harness --out --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. diff --git a/acp.go b/acp.go index 3311012..684bb11 100644 --- a/acp.go +++ b/acp.go @@ -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 { } diff --git a/cleanup_test.go b/cleanup_test.go index 3d134b0..5975029 100644 --- a/cleanup_test.go +++ b/cleanup_test.go @@ -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() diff --git a/cmd/acp/report_test.go b/cmd/acp/report_test.go index 7507dd8..ea24beb 100644 --- a/cmd/acp/report_test.go +++ b/cmd/acp/report_test.go @@ -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 { diff --git a/copy.go b/copy.go index 75512ab..0635a68 100644 --- a/copy.go +++ b/copy.go @@ -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 } diff --git a/copy_eof_test.go b/copy_eof_test.go new file mode 100644 index 0000000..4b5a231 --- /dev/null +++ b/copy_eof_test.go @@ -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) + } + }) + } +} diff --git a/copy_test.go b/copy_test.go index 7d6540b..9ee806f 100644 --- a/copy_test.go +++ b/copy_test.go @@ -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") } } diff --git a/dev/ltfs_compare.py b/dev/ltfs_compare.py new file mode 100644 index 0000000..e364786 --- /dev/null +++ b/dev/ltfs_compare.py @@ -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() diff --git a/dev/release.py b/dev/release.py index 15673b9..8213f42 100644 --- a/dev/release.py +++ b/dev/release.py @@ -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") diff --git a/dev/test_ltfs_compare.py b/dev/test_ltfs_compare.py new file mode 100644 index 0000000..cdcbce9 --- /dev/null +++ b/dev/test_ltfs_compare.py @@ -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) diff --git a/dev/test_release.py b/dev/test_release.py index aa2329b..7598dc3 100644 --- a/dev/test_release.py +++ b/dev/test_release.py @@ -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"/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], diff --git a/disk_usage.go b/disk_usage.go index 27df963..c2a7001 100644 --- a/disk_usage.go +++ b/disk_usage.go @@ -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 { diff --git a/e2e_test.go b/e2e_test.go index 79525bc..2438962 100644 --- a/e2e_test.go +++ b/e2e_test.go @@ -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) { diff --git a/filesystem.go b/filesystem.go new file mode 100644 index 0000000..1d948e5 --- /dev/null +++ b/filesystem.go @@ -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} +} diff --git a/full_linux_test.go b/full_linux_test.go index c0c101b..45c7027 100644 --- a/full_linux_test.go +++ b/full_linux_test.go @@ -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 { diff --git a/item_test.go b/item_test.go index 5629932..3a76b2d 100644 --- a/item_test.go +++ b/item_test.go @@ -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) } diff --git a/job.go b/job.go index ba457d8..c8b4556 100644 --- a/job.go +++ b/job.go @@ -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 diff --git a/linear_space_test.go b/linear_space_test.go new file mode 100644 index 0000000..a29df2c --- /dev/null +++ b/linear_space_test.go @@ -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) + } +} diff --git a/ltfs_benchmark_test.go b/ltfs_benchmark_test.go new file mode 100644 index 0000000..74ef60a --- /dev/null +++ b/ltfs_benchmark_test.go @@ -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)) + } + }) +} diff --git a/ltfs_mock_test.go b/ltfs_mock_test.go new file mode 100644 index 0000000..ecc70a0 --- /dev/null +++ b/ltfs_mock_test.go @@ -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 +} diff --git a/pipeline_b_test.go b/pipeline_b_test.go new file mode 100644 index 0000000..d5efdb2 --- /dev/null +++ b/pipeline_b_test.go @@ -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) + } + } + }) +} diff --git a/pipeline_liveness_test.go b/pipeline_liveness_test.go new file mode 100644 index 0000000..89ad308 --- /dev/null +++ b/pipeline_liveness_test.go @@ -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") + } +} diff --git a/prepare.go b/prepare.go index ef114ca..591b8e9 100644 --- a/prepare.go +++ b/prepare.go @@ -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() { diff --git a/prepare_content_test.go b/prepare_content_test.go new file mode 100644 index 0000000..49cd6e0 --- /dev/null +++ b/prepare_content_test.go @@ -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) +} diff --git a/publication_serial_test.go b/publication_serial_test.go new file mode 100644 index 0000000..779e33d --- /dev/null +++ b/publication_serial_test.go @@ -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) + } + }) +} diff --git a/read_ahead.go b/read_ahead.go new file mode 100644 index 0000000..1553c69 --- /dev/null +++ b/read_ahead.go @@ -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 +} diff --git a/read_ahead_test.go b/read_ahead_test.go new file mode 100644 index 0000000..f3c9346 --- /dev/null +++ b/read_ahead_test.go @@ -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 + }) +} diff --git a/signature.go b/signature.go index f406dd5..a2facfc 100644 --- a/signature.go +++ b/signature.go @@ -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 } diff --git a/signature_test.go b/signature_test.go index f9ccb9f..994b806 100644 --- a/signature_test.go +++ b/signature_test.go @@ -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 diff --git a/signature_xattr_freebsd.go b/signature_xattr_freebsd.go index f041f5e..7624bfb 100644 --- a/signature_xattr_freebsd.go +++ b/signature_xattr_freebsd.go @@ -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) } diff --git a/signature_xattr_other.go b/signature_xattr_other.go index 8bcb2cc..6c64082 100644 --- a/signature_xattr_other.go +++ b/signature_xattr_other.go @@ -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 } diff --git a/signature_xattr_unix.go b/signature_xattr_unix.go index 366ddf3..989ee12 100644 --- a/signature_xattr_unix.go +++ b/signature_xattr_unix.go @@ -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) } diff --git a/tape_drive_test.go b/tape_drive_test.go new file mode 100644 index 0000000..d9400de --- /dev/null +++ b/tape_drive_test.go @@ -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) + } + }) +} diff --git a/tape_speed_test.go b/tape_speed_test.go new file mode 100644 index 0000000..3a547a3 --- /dev/null +++ b/tape_speed_test.go @@ -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") + } + }) +} diff --git a/target.go b/target.go index 97a8214..f824395 100644 --- a/target.go +++ b/target.go @@ -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() diff --git a/target_identity_test.go b/target_identity_test.go new file mode 100644 index 0000000..1d8024a --- /dev/null +++ b/target_identity_test.go @@ -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) + } + } + }) + } +} diff --git a/target_test.go b/target_test.go index c57317f..b01fd71 100644 --- a/target_test.go +++ b/target_test.go @@ -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) { diff --git a/transfer.go b/transfer.go new file mode 100644 index 0000000..a2951b6 --- /dev/null +++ b/transfer.go @@ -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") } diff --git a/transfer_read.go b/transfer_read.go new file mode 100644 index 0000000..9fd52e9 --- /dev/null +++ b/transfer_read.go @@ -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 +} diff --git a/transfer_target.go b/transfer_target.go new file mode 100644 index 0000000..99e5e89 --- /dev/null +++ b/transfer_target.go @@ -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) + } +} diff --git a/workload_bench_test.go b/workload_bench_test.go index 35bdd1a..febe542 100644 --- a/workload_bench_test.go +++ b/workload_bench_test.go @@ -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) }