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


##########
table/orphan_cleanup.go:
##########
@@ -712,23 +714,44 @@ func deleteFiles(ctx context.Context, fs iceio.IO, 
orphanFiles []string, cfg *or
                }
        }
 
-       if cfg.maxConcurrency == 1 {
-               return deleteFilesSequential(fs, orphanFiles, cfg)
+       if cfg.maxConcurrency <= 1 {
+               return deleteFilesSequential(ctx, fs, orphanFiles, cfg)
+       }
+
+       deleteFunc := fs.Remove
+       if cfg.deleteFunc != nil {
+               deleteFunc = cfg.deleteFunc
        }
 
-       return deleteFilesParallel(fs, orphanFiles, cfg)
+       return deleteFilesParallel(
+               ctx,
+               orphanFiles,
+               cfg.maxConcurrency,
+               deleteFunc,
+               func(file string, err error) error {
+                       return fmt.Errorf("failed to delete orphan file %s: 
%w", file, err)
+               },
+       )
 }
 
-func deleteFilesSequential(fs iceio.IO, orphanFiles []string, cfg 
*orphanCleanupConfig) ([]string, error) {
+func deleteFilesSequential(ctx context.Context, fs iceio.IO, orphanFiles 
[]string, cfg *orphanCleanupConfig) ([]string, error) {
        var deletedFiles []string
 
        deleteFunc := fs.Remove
        if cfg.deleteFunc != nil {
                deleteFunc = cfg.deleteFunc
        }
 
-       var result error
+       var (
+               result          error
+               cancellationErr error
+       )
        for _, file := range orphanFiles {
+               if err := ctx.Err(); err != nil {

Review Comment:
   **minor** — New ctx cancellation guard in deleteFilesSequential is pinned by 
no test
   
   This PR adds context cancellation to deleteFilesSequential (the function 
previously took no ctx at all). The guard is production-reachable: deleteFiles 
routes here when cfg.maxConcurrency <= 1, and maxConcurrency defaults to 
runtime.GOMAXPROCS(0) (orphan_cleanup.go:284), so single-CPU containers take 
this path by default; WithCleanupMaxConcurrency(1) also selects it. The 
parallel twin is now well pinned, but this one is not. Suggest a small test 
that cancels ctx after N sequential deletes and asserts the returned error is 
context.Canceled and that len(deletedFiles) < len(orphanFiles).



##########
table/orphan_cleanup_test.go:
##########
@@ -1616,6 +1624,243 @@ 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
+
+       release chan struct{}
+
+       mu        sync.Mutex
+       active    int
+       maxActive int
+       removed   []string
+}
+
+func (m *purgeDeleteTrackingIO) Remove(name string) error {
+       m.mu.Lock()
+       m.active++
+       m.maxActive = max(m.maxActive, m.active)
+       m.mu.Unlock()
+
+       <-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 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 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)
+}
+
+// gatedErrContext lets a test cancel after a job is received, before Err 
returns.
+type gatedErrContext struct {
+       context.Context
+       checks <-chan struct{}
+}
+
+func (c gatedErrContext) Err() error {
+       <-c.checks
+
+       return c.Context.Err()
+}
+
+func TestDeleteFilesParallelStopsQueuedWorkOnMidFlightCancellation(t 
*testing.T) {
+       synctest.Test(t, func(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()
+
+               checks := make(chan struct{}, maxConcurrency)
+               for range maxConcurrency {
+                       checks <- struct{}{}
+               }
+               var calls atomic.Int32
+               release := make(chan struct{})
+               var deleted []string
+               var err error
+               done := make(chan struct{})
+               go func() {
+                       defer close(done)
+                       deleted, err = deleteFilesParallel(
+                               gatedErrContext{Context: ctx, checks: checks},
+                               files,
+                               maxConcurrency,
+                               func(string) error {
+                                       calls.Add(1)
+                                       <-release
+
+                                       return nil
+                               },
+                               func(path string, err error) error {
+                                       return fmt.Errorf("failed to remove %s: 
%w", path, err)
+                               },
+                       )
+               }()
+
+               synctest.Wait()
+               assert.Equal(t, int32(maxConcurrency), calls.Load())
+
+               // Let one worker receive another job, then cancel while its 
Err check is paused.
+               release <- struct{}{}
+               synctest.Wait()
+               cancel()
+               close(checks)
+               close(release)
+               <-done
+
+               require.ErrorIs(t, err, context.Canceled)
+               assert.Equal(t, int32(maxConcurrency), calls.Load())
+               assert.Equal(t, files[:maxConcurrency], deleted)
+       })
+}
+
+func TestPurgeFilesDeletesNonBulkFilesConcurrently(t *testing.T) {
+       synctest.Test(t, func(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},
+                       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()) }()
+
+               // Every worker remains blocked in Remove until the full pool 
is observable.
+               synctest.Wait()
+               assert.Greater(t, maxWorkers, 1)
+               assert.Equal(t, maxWorkers, fsys.MaxActive())

Review Comment:
   **nit** — assert.Greater(t, maxWorkers, 1) is a compile-time-constant 
tautology
   
   maxWorkers is 'const maxWorkers = defaultPurgeMaxConcurrency' (32), so this 
asserts 32 > 1 and exercises no code under test. The adjacent assert.Equal(t, 
maxWorkers, fsys.MaxActive()) already carries the real signal. Harmless, but it 
is exactly the kind of assertion that reads as coverage without providing any.



-- 
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