Api#2880: Close the underlying iterator in ClosingIterator in hasNext() call (#2881)

diff --git a/api/src/main/java/org/apache/iceberg/io/ClosingIterator.java b/api/src/main/java/org/apache/iceberg/io/ClosingIterator.java
index 26575fe..58fc931 100644
--- a/api/src/main/java/org/apache/iceberg/io/ClosingIterator.java
+++ b/api/src/main/java/org/apache/iceberg/io/ClosingIterator.java
@@ -23,35 +23,38 @@
 import java.io.UncheckedIOException;
 import java.util.Iterator;
 
+/**
+ * A convenience wrapper around {@link CloseableIterator}, providing auto-close
+ * functionality when all of the elements in the iterator are consumed.
+ */
 public class ClosingIterator<T> implements Iterator<T> {
   private final CloseableIterator<T> iterator;
-  private boolean shouldClose = false;
+  private boolean isClosed;
 
   public ClosingIterator(CloseableIterator<T> iterator) {
     this.iterator = iterator;
-
   }
 
   @Override
   public boolean hasNext() {
     boolean hasNext = iterator.hasNext();
-    this.shouldClose = !hasNext;
+    if (!hasNext && !isClosed) {
+      close();
+    }
     return hasNext;
   }
 
   @Override
   public T next() {
-    T next = iterator.next();
+    return iterator.next();
+  }
 
-    if (shouldClose) {
-      // this will only be called once because iterator.next would throw NoSuchElementException
-      try {
-        iterator.close();
-      } catch (IOException e) {
-        throw new UncheckedIOException(e);
-      }
+  private void close() {
+    try {
+      iterator.close();
+      isClosed = true;
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
     }
-
-    return next;
   }
 }
diff --git a/api/src/test/java/org/apache/iceberg/io/TestClosingIterator.java b/api/src/test/java/org/apache/iceberg/io/TestClosingIterator.java
new file mode 100644
index 0000000..5aebc2f
--- /dev/null
+++ b/api/src/test/java/org/apache/iceberg/io/TestClosingIterator.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iceberg.io;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class TestClosingIterator {
+  @Test
+  public void testEmptyIterator() {
+    CloseableIterator<String> underlying = mock(CloseableIterator.class);
+    ClosingIterator<String> closingIterator = new ClosingIterator<>(underlying);
+    assertFalse(closingIterator.hasNext());
+  }
+
+  @Test
+  public void testHasNextAndNext() {
+    CloseableIterator<String> underlying = mock(CloseableIterator.class);
+    when(underlying.hasNext()).thenReturn(true);
+    when(underlying.next()).thenReturn("hello");
+    ClosingIterator<String> closingIterator = new ClosingIterator<>(underlying);
+    assertTrue(closingIterator.hasNext());
+    assertEquals("hello", closingIterator.next());
+  }
+
+  @Test
+  public void testUnderlyingIteratorCloseWhenElementsAreExhausted() throws Exception {
+    CloseableIterator<String> underlying = mock(CloseableIterator.class);
+    when(underlying.hasNext()).thenReturn(true).thenReturn(false);
+    when(underlying.next()).thenReturn("hello");
+    ClosingIterator<String> closingIterator = new ClosingIterator<>(underlying);
+    assertTrue(closingIterator.hasNext());
+    assertEquals("hello", closingIterator.next());
+
+    assertFalse(closingIterator.hasNext());
+    verify(underlying, times(1)).close();
+  }
+
+  @Test
+  public void testCloseCalledOnceForMultipleHasNextCalls() throws Exception {
+    CloseableIterator<String> underlying = mock(CloseableIterator.class);
+    ClosingIterator<String> closingIterator = new ClosingIterator<>(underlying);
+    assertFalse(closingIterator.hasNext());
+    assertFalse(closingIterator.hasNext());
+    verify(underlying, times(1)).close();
+  }
+}