zeroshade commented on code in PR #1973:
URL: https://github.com/apache/iceberg-go/pull/1973#discussion_r3937964158


##########
table/orphan_cleanup_test.go:
##########
@@ -1616,6 +1623,256 @@ func (m mockFileInfo) ModTime() time.Time   { return 
m.modTime }
 func (m mockFileInfo) IsDir() bool          { return m.mode.IsDir() }
 func (m mockFileInfo) Sys() any             { return nil }
 
+type purgeDeleteTrackingIO struct {
+       mockListableIO
+
+       targetActive int
+       reached      chan struct{}
+       release      chan struct{}
+
+       mu          sync.Mutex
+       active      int
+       maxActive   int
+       removed     []string
+       reachedOnce sync.Once
+       releaseOnce sync.Once
+}
+
+func (m *purgeDeleteTrackingIO) Remove(name string) error {
+       m.mu.Lock()
+       m.active++
+       if m.active > m.maxActive {
+               m.maxActive = m.active
+       }
+       if m.targetActive > 0 && m.active >= m.targetActive {
+               m.reachedOnce.Do(func() { close(m.reached) })
+       }
+       m.mu.Unlock()
+
+       if m.targetActive > 0 {
+               <-m.release
+       }
+
+       m.mu.Lock()
+       m.active--
+       m.removed = append(m.removed, name)
+       m.mu.Unlock()
+
+       return nil
+}
+
+func (m *purgeDeleteTrackingIO) MaxActive() int {
+       m.mu.Lock()
+       defer m.mu.Unlock()
+
+       return m.maxActive
+}
+
+func (m *purgeDeleteTrackingIO) Release() {
+       m.releaseOnce.Do(func() { close(m.release) })
+}
+
+func TestDeleteFilesParallelCollectsPurgeErrors(t *testing.T) {
+       const (
+               firstPath   = "s3://bucket/table/first.parquet"
+               missingPath = "s3://bucket/table/missing.parquet"
+               slowPath    = "s3://bucket/table/slow.parquet"
+               fastPath    = "s3://bucket/table/fast.parquet"
+       )
+
+       slowErr := errors.New("slow removal failed")
+       fastErr := errors.New("fast removal failed")
+       files := []string{firstPath, missingPath, slowPath, fastPath}
+       var mu sync.Mutex
+       calls := make(map[string]int)
+
+       deleted, err := deleteFilesParallel(
+               context.Background(),
+               files,
+               4,
+               func(path string) error {
+                       mu.Lock()
+                       calls[path]++
+                       mu.Unlock()
+
+                       var err error
+                       switch path {
+                       case missingPath:
+                               err = stdfs.ErrNotExist
+                       case slowPath:
+                               time.Sleep(10 * time.Millisecond)
+
+                               err = slowErr
+                       case fastPath:
+                               err = fastErr
+                       }
+                       if os.IsNotExist(err) {
+                               return nil
+                       }
+
+                       return err
+               },
+               func(path string, err error) error {
+                       return fmt.Errorf("failed to remove %s: %w", path, err)
+               },
+       )
+
+       assert.Equal(t, []string{firstPath, missingPath}, deleted)
+       require.ErrorIs(t, err, slowErr)
+       require.ErrorIs(t, err, fastErr)
+       assert.NotContains(t, err.Error(), missingPath)
+       assert.Less(t, strings.Index(err.Error(), slowPath), 
strings.Index(err.Error(), fastPath))
+
+       mu.Lock()
+       assert.Equal(t, map[string]int{
+               firstPath:   1,
+               missingPath: 1,
+               slowPath:    1,
+               fastPath:    1,
+       }, calls)
+       mu.Unlock()
+}
+
+func TestDeleteFilesParallelPreservesErrorWithNilWrapper(t *testing.T) {
+       deleteErr := errors.New("delete failed")
+       files := []string{
+               "s3://bucket/table/ok.parquet",
+               "s3://bucket/table/failed.parquet",
+       }
+
+       deleted, err := deleteFilesParallel(
+               context.Background(),
+               files,
+               2,
+               func(path string) error {
+                       if path == files[1] {
+                               return deleteErr
+                       }
+
+                       return nil
+               },
+               func(string, error) error { return nil },
+       )
+
+       assert.Equal(t, []string{files[0]}, deleted)
+       require.ErrorIs(t, err, deleteErr)
+}
+
+func TestDeleteFilesParallelStopsQueuedWorkOnCancellation(t *testing.T) {
+       ctx, cancel := context.WithCancel(context.Background())
+       cancel()
+
+       called := false
+       _, err := deleteFilesParallel(
+               ctx,
+               []string{"s3://bucket/table/file.parquet"},
+               4,
+               func(string) error {
+                       called = true
+
+                       return nil
+               },
+               func(path string, err error) error {
+                       return fmt.Errorf("failed to remove %s: %w", path, err)
+               },
+       )
+
+       require.ErrorIs(t, err, context.Canceled)
+       assert.False(t, called)
+}
+
+func TestDeleteFilesParallelStopsQueuedWorkOnMidFlightCancellation(t 
*testing.T) {
+       const (
+               fileCount      = 50
+               maxConcurrency = 4
+       )
+
+       files := make([]string, fileCount)
+       for i := range files {
+               files[i] = fmt.Sprintf("s3://bucket/table/file-%02d.parquet", i)
+       }
+
+       ctx, cancel := context.WithCancel(context.Background())
+       defer cancel()
+
+       var calls atomic.Int32
+       release := make(chan struct{})
+       deleted, err := deleteFilesParallel(
+               ctx,
+               files,
+               maxConcurrency,
+               func(string) error {
+                       call := calls.Add(1)
+                       if call == maxConcurrency {
+                               cancel()
+                               close(release)
+                       } else if call < maxConcurrency {
+                               <-release
+                       }
+
+                       return nil
+               },
+               func(path string, err error) error {
+                       return fmt.Errorf("failed to remove %s: %w", path, err)
+               },
+       )
+
+       require.ErrorIs(t, err, context.Canceled)
+       assert.Equal(t, int32(maxConcurrency), calls.Load())
+       assert.Less(t, len(deleted), fileCount)
+}
+
+func TestPurgeFilesDeletesNonBulkFilesConcurrently(t *testing.T) {
+       const maxWorkers = defaultPurgeMaxConcurrency
+       const fileCount = maxWorkers * 2
+       entries := make([]mockWalkEntry, 0, fileCount)
+       for i := range fileCount {
+               entries = append(entries, mockWalkEntry{
+                       path: 
fmt.Sprintf("s3://bucket/table/data/file-%02d.parquet", i),
+                       info: mockFileInfo{name: 
fmt.Sprintf("file-%02d.parquet", i)},
+               })
+       }
+
+       fsys := &purgeDeleteTrackingIO{
+               mockListableIO: mockListableIO{entries: entries},
+               targetActive:   2,
+               reached:        make(chan struct{}),
+               release:        make(chan struct{}),
+       }
+
+       meta, err := NewMetadata(
+               iceberg.NewSchema(0),
+               iceberg.UnpartitionedSpec,
+               UnsortedSortOrder,
+               "s3://bucket/table",
+               iceberg.Properties{},
+       )
+       require.NoError(t, err)
+       tbl := New(
+               Identifier{"db", "tbl"},
+               meta,
+               "s3://bucket/table/metadata/v1.metadata.json",
+               testFSF(fsys),
+               nil,
+       )
+
+       done := make(chan error, 1)
+       go func() { done <- tbl.PurgeFiles(context.Background()) }()
+
+       select {
+       case <-fsys.reached:
+       case <-time.After(5 * time.Second):
+               fsys.Release()
+               <-done
+               t.Fatal("non-bulk purge deletion did not reach two concurrent 
removals")
+       }
+       fsys.Release()
+
+       require.NoError(t, <-done)
+       assert.Greater(t, fsys.MaxActive(), 1)
+       assert.LessOrEqual(t, fsys.MaxActive(), maxWorkers)
+}

Review Comment:
   **major** — assert.LessOrEqual(fsys.MaxActive(), maxWorkers) is vacuous - 
the concurrency cap is unverified
   
   purgeDeleteTrackingIO is constructed with targetActive: 2 (line 1838), so 
the test releases the barrier as soon as two removals are concurrently active. 
The worker pool never saturates, so MaxActive() never approaches the cap and 
the upper-bound assertion can never fail. Bounded concurrency is the headline 
safety property of this PR and nothing pins it. Fix: set targetActive to 
defaultPurgeMaxConcurrency so all workers are held in flight, then assert 
MaxActive() == defaultPurgeMaxConcurrency exactly.
   
   <details><summary>Evidence</summary>
   
   ```text
   Replaced `workers := min(max(maxConcurrency, 1), len(files))` 
(orphan_cleanup.go:773) with `workers := len(files)` -- 64 workers against a 
documented cap of 32 -- and `go test -count=50 -run 
TestPurgeFilesDeletesNonBulkFilesConcurrently ./table/` => `ok ... 0.556s` 
(50/50 pass). A probe using a barrier that actually holds every worker observed 
the correct behaviour: 'PROBE-A bound=8 files=64 observedPeak=8', confirming 
the code is right and only the assertion is toothless.
   ```
   
   </details>



##########
table/orphan_cleanup.go:
##########
@@ -737,58 +760,95 @@ func deleteFilesSequential(fs iceio.IO, orphanFiles 
[]string, cfg *orphanCleanup
                deletedFiles = append(deletedFiles, file)
        }
 
-       return deletedFiles, result
+       return deletedFiles, errors.Join(result, cancellationErr)
 }
 
-func deleteFilesParallel(fs iceio.IO, orphanFiles []string, cfg 
*orphanCleanupConfig) ([]string, error) {
-       deleteFunc := fs.Remove
-       if cfg.deleteFunc != nil {
-               deleteFunc = cfg.deleteFunc
+func deleteFilesParallel(
+       ctx context.Context,
+       files []string,
+       maxConcurrency int,
+       deleteFunc func(string) error,
+       wrapError func(string, error) error,
+) ([]string, error) {
+       workers := min(max(maxConcurrency, 1), len(files))
+       jobs := make(chan int)
+       deleted := make([]bool, len(files))
+       deleteErrors := make([]error, len(files))
+
+       var cancellationErr error
+       var cancellationOnce sync.Once
+       recordCancellation := func(err error) {
+               cancellationOnce.Do(func() {
+                       cancellationErr = err
+               })
        }
 
-       in := make(chan string, cfg.maxConcurrency)
-       out := make(chan string, cfg.maxConcurrency)
-       errList := make([][]error, cfg.maxConcurrency)
-
-       go func() {
-               defer close(in)
-               for _, file := range orphanFiles {
-                       in <- file
-               }
-       }()
-
        var wg sync.WaitGroup
-       wg.Add(cfg.maxConcurrency)
-       for i := range cfg.maxConcurrency {
-               go func(workerID int) {
+       wg.Add(workers)
+       for range workers {
+               go func() {
                        defer wg.Done()
-                       for file := range in {
-                               if err := deleteFunc(file); err != nil {
-                                       errList[workerID] = 
append(errList[workerID], fmt.Errorf("failed to delete orphan file %s: %w", 
file, err))
-                               } else {
-                                       out <- file
+                       for {
+                               select {
+                               case <-ctx.Done():
+                                       recordCancellation(ctx.Err())
+
+                                       return
+                               case index, ok := <-jobs:
+                                       if !ok {
+                                               return
+                                       }
+                                       // Both cases can be ready after 
cancellation, so check the context
+                                       // again before starting the deletion.
+                                       if err := ctx.Err(); err != nil {
+                                               recordCancellation(err)
+
+                                               return
+                                       }
+
+                                       if err := deleteFunc(files[index]); err 
!= nil {
+                                               wrappedErr := 
wrapError(files[index], err)
+                                               if wrappedErr == nil {
+                                                       // Keep failed 
deletions observable even if the wrapper suppresses the error.

Review Comment:
   **minor** — Defensive branch for a condition no caller can produce, plus a 
test that exists only to cover it
   
   `if wrappedErr == nil { wrappedErr = err }` guards against a wrapError that 
returns nil. Both production call sites (orphan_cleanup.go:731 and :1409) 
unconditionally return a non-nil fmt.Errorf, so this is unreachable in 
production; the only caller that can trigger it is 
TestDeleteFilesParallelPreservesErrorWithNilWrapper, written for that purpose. 
The prior review asked for the bench's wrapError to be fixed (it was); this 
production fallback is extra. AGENTS/house style: don't add error handling for 
scenarios that can't happen. Suggest dropping both the branch and the test.



##########
io/io.go:
##########
@@ -59,6 +59,7 @@ type IO interface {
        // Remove removes the named file or (empty) directory.
        //
        // If there is an error, it will be of type *PathError.
+       // Implementations must be safe for concurrent use by multiple 
goroutines.
        Remove(name string) error

Review Comment:
   **minor** — Retroactive concurrency contract on the public IO interface 
deserves a release note
   
   'Implementations must be safe for concurrent use by multiple goroutines.' 
tightens the contract of an exported extension point. Downstream users who 
registered a custom IO via io.Register and whose Remove was only ever driven 
serially by PurgeFiles will now be called from up to 32 goroutines. Every 
in-repo implementation already complies, so nothing breaks here, but this is a 
behavioural change for third-party implementors and should be called out in the 
changelog rather than only in a doc comment.



##########
table/orphan_cleanup_test.go:
##########
@@ -1616,6 +1623,256 @@ func (m mockFileInfo) ModTime() time.Time   { return 
m.modTime }
 func (m mockFileInfo) IsDir() bool          { return m.mode.IsDir() }
 func (m mockFileInfo) Sys() any             { return nil }
 
+type purgeDeleteTrackingIO struct {
+       mockListableIO
+
+       targetActive int
+       reached      chan struct{}
+       release      chan struct{}
+
+       mu          sync.Mutex
+       active      int
+       maxActive   int
+       removed     []string
+       reachedOnce sync.Once
+       releaseOnce sync.Once
+}
+
+func (m *purgeDeleteTrackingIO) Remove(name string) error {
+       m.mu.Lock()
+       m.active++
+       if m.active > m.maxActive {
+               m.maxActive = m.active
+       }
+       if m.targetActive > 0 && m.active >= m.targetActive {
+               m.reachedOnce.Do(func() { close(m.reached) })
+       }
+       m.mu.Unlock()
+
+       if m.targetActive > 0 {
+               <-m.release
+       }
+
+       m.mu.Lock()
+       m.active--
+       m.removed = append(m.removed, name)
+       m.mu.Unlock()
+
+       return nil
+}
+
+func (m *purgeDeleteTrackingIO) MaxActive() int {
+       m.mu.Lock()
+       defer m.mu.Unlock()
+
+       return m.maxActive
+}
+
+func (m *purgeDeleteTrackingIO) Release() {
+       m.releaseOnce.Do(func() { close(m.release) })
+}
+
+func TestDeleteFilesParallelCollectsPurgeErrors(t *testing.T) {
+       const (
+               firstPath   = "s3://bucket/table/first.parquet"
+               missingPath = "s3://bucket/table/missing.parquet"
+               slowPath    = "s3://bucket/table/slow.parquet"
+               fastPath    = "s3://bucket/table/fast.parquet"
+       )
+
+       slowErr := errors.New("slow removal failed")
+       fastErr := errors.New("fast removal failed")
+       files := []string{firstPath, missingPath, slowPath, fastPath}
+       var mu sync.Mutex
+       calls := make(map[string]int)
+
+       deleted, err := deleteFilesParallel(
+               context.Background(),
+               files,
+               4,
+               func(path string) error {
+                       mu.Lock()
+                       calls[path]++
+                       mu.Unlock()
+
+                       var err error
+                       switch path {
+                       case missingPath:
+                               err = stdfs.ErrNotExist
+                       case slowPath:
+                               time.Sleep(10 * time.Millisecond)
+
+                               err = slowErr
+                       case fastPath:
+                               err = fastErr
+                       }
+                       if os.IsNotExist(err) {
+                               return nil
+                       }
+
+                       return err
+               },
+               func(path string, err error) error {
+                       return fmt.Errorf("failed to remove %s: %w", path, err)
+               },
+       )
+
+       assert.Equal(t, []string{firstPath, missingPath}, deleted)
+       require.ErrorIs(t, err, slowErr)
+       require.ErrorIs(t, err, fastErr)
+       assert.NotContains(t, err.Error(), missingPath)
+       assert.Less(t, strings.Index(err.Error(), slowPath), 
strings.Index(err.Error(), fastPath))
+
+       mu.Lock()
+       assert.Equal(t, map[string]int{
+               firstPath:   1,
+               missingPath: 1,
+               slowPath:    1,
+               fastPath:    1,
+       }, calls)
+       mu.Unlock()
+}
+
+func TestDeleteFilesParallelPreservesErrorWithNilWrapper(t *testing.T) {
+       deleteErr := errors.New("delete failed")
+       files := []string{
+               "s3://bucket/table/ok.parquet",
+               "s3://bucket/table/failed.parquet",
+       }
+
+       deleted, err := deleteFilesParallel(
+               context.Background(),
+               files,
+               2,
+               func(path string) error {
+                       if path == files[1] {
+                               return deleteErr
+                       }
+
+                       return nil
+               },
+               func(string, error) error { return nil },
+       )
+
+       assert.Equal(t, []string{files[0]}, deleted)
+       require.ErrorIs(t, err, deleteErr)
+}
+
+func TestDeleteFilesParallelStopsQueuedWorkOnCancellation(t *testing.T) {
+       ctx, cancel := context.WithCancel(context.Background())
+       cancel()
+
+       called := false
+       _, err := deleteFilesParallel(
+               ctx,
+               []string{"s3://bucket/table/file.parquet"},
+               4,
+               func(string) error {
+                       called = true
+
+                       return nil
+               },
+               func(path string, err error) error {
+                       return fmt.Errorf("failed to remove %s: %w", path, err)
+               },
+       )
+
+       require.ErrorIs(t, err, context.Canceled)
+       assert.False(t, called)
+}
+
+func TestDeleteFilesParallelStopsQueuedWorkOnMidFlightCancellation(t 
*testing.T) {
+       const (

Review Comment:
   **major** — Mid-flight cancellation test still does not reach the 
post-receive ctx guard
   
   TestDeleteFilesParallelStopsQueuedWorkOnMidFlightCancellation calls cancel() 
and only then close(release), so ctx.Done() is always ready strictly before any 
worker can return to the `case index, ok := <-jobs` receive. The sender 
therefore always wins the select race and no worker ever reaches the inner 
guard at orphan_cleanup.go:801-806. This is the exact defect the prior review 
named ('would still pass if that guard were deleted') and it is still true. 
Fix: close(release) BEFORE cancel() (or release a subset first) so workers are 
contending on the jobs receive at the moment of cancellation, and assert 
deleteFunc is not invoked again after cancellation is observed.
   
   <details><summary>Evidence</summary>
   
   ```text
   Deleted the guard block at orphan_cleanup.go:801-806 -> `go test -count=200 
-run TestDeleteFilesParallelStopsQueuedWorkOnMidFlightCancellation ./table/` => 
`ok ... 0.582s`; full `go test -race -count=1 ./table/` => `ok ... 20.047s`. 
Instrumented guard with an atomic counter: 'guard fired total: 0' across 
TestDeleteFiles*/TestPurgeFiles*/TestOrphan*. An unbarriered probe on the same 
code recorded 119 firings across 300 cancelled runs, proving the guard is 
reachable and load-bearing -- just never by the suite.
   ```
   
   </details>



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to