Skip to content

Commit 2f4ac8f

Browse files
Youngwbclaude
andauthored
[BugFix] Make Iceberg incremental scan-range iterator safe to close concurrently (#75953)
Signed-off-by: Youngwb <yangwenbo_mailbox@163.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 23a4a4b commit 2f4ac8f

3 files changed

Lines changed: 216 additions & 2 deletions

File tree

fe/fe-core/src/main/java/com/starrocks/connector/iceberg/IcebergMetadata.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1358,7 +1358,7 @@ public FileScanTask next() {
13581358
}
13591359

13601360
Scan tableScan = scan;
1361-
return new CloseableIterator<>() {
1361+
return new SynchronizedCloseableIterator<>(new CloseableIterator<>() {
13621362
CloseableIterable<FileScanTask> fileScanTaskIterable;
13631363
CloseableIterator<FileScanTask> fileScanTaskIterator;
13641364
boolean hasMore = true;
@@ -1407,7 +1407,7 @@ private void closePlannedTaskIterator() {
14071407
public void close() {
14081408
closePlannedTaskIterator();
14091409
}
1410-
};
1410+
});
14111411
}
14121412

14131413
private boolean hasPositionDeletes(FileScanTask task) {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright 2021-present StarRocks, Inc. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.starrocks.connector.iceberg;
16+
17+
import org.apache.iceberg.io.CloseableIterator;
18+
19+
import java.io.IOException;
20+
import java.util.NoSuchElementException;
21+
22+
/**
23+
* Makes a delegate CloseableIterator safe to close() from one thread while it is
24+
* consumed on another. The incremental scan-range scheduler consumes the iterator on
25+
* an executor thread while query cancellation closes the scan source on a separate
26+
* cleanup thread; without this, a concurrent close() lets the consumer touch an
27+
* already-closed underlying iterable (Iceberg's ParallelIterable throws "Already closed").
28+
*
29+
* <p>hasNext/next/close are serialized, and hasNext() prefetches one element so a
30+
* close() landing between a caller's hasNext() and next() still lets that next() return
31+
* the buffered element. Once closed, hasNext() reports exhausted (false) so a closed
32+
* source never advertises another batch to a standalone probe, and the delegate is
33+
* never accessed again.
34+
*/
35+
public class SynchronizedCloseableIterator<T> implements CloseableIterator<T> {
36+
private final CloseableIterator<T> delegate;
37+
private T buffered;
38+
private boolean hasBuffered = false;
39+
private boolean closed = false;
40+
41+
public SynchronizedCloseableIterator(CloseableIterator<T> delegate) {
42+
this.delegate = delegate;
43+
}
44+
45+
@Override
46+
public synchronized boolean hasNext() {
47+
if (closed) {
48+
return false;
49+
}
50+
if (hasBuffered) {
51+
return true;
52+
}
53+
if (delegate.hasNext()) {
54+
buffered = delegate.next();
55+
hasBuffered = true;
56+
return true;
57+
}
58+
return false;
59+
}
60+
61+
@Override
62+
public synchronized T next() {
63+
// A buffered element prefetched before close() is still returned, so a next()
64+
// racing a concurrent close() does not throw.
65+
if (hasBuffered) {
66+
T next = buffered;
67+
buffered = null;
68+
hasBuffered = false;
69+
return next;
70+
}
71+
if (closed || !delegate.hasNext()) {
72+
throw new NoSuchElementException();
73+
}
74+
return delegate.next();
75+
}
76+
77+
@Override
78+
public synchronized void close() throws IOException {
79+
if (closed) {
80+
return;
81+
}
82+
closed = true;
83+
delegate.close();
84+
}
85+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright 2021-present StarRocks, Inc. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.starrocks.connector.iceberg;
16+
17+
import org.apache.iceberg.io.CloseableIterator;
18+
import org.junit.jupiter.api.Assertions;
19+
import org.junit.jupiter.api.Test;
20+
21+
import java.util.NoSuchElementException;
22+
import java.util.concurrent.CountDownLatch;
23+
import java.util.concurrent.atomic.AtomicReference;
24+
25+
public class SynchronizedCloseableIteratorTest {
26+
27+
// Mimics Iceberg's ParallelIterable: once closed, hasNext()/next() throw
28+
// IllegalStateException("Already closed").
29+
private static class AlreadyClosedIterator implements CloseableIterator<Integer> {
30+
private int index = 0;
31+
private final int size;
32+
private volatile boolean closed = false;
33+
34+
AlreadyClosedIterator(int size) {
35+
this.size = size;
36+
}
37+
38+
@Override
39+
public boolean hasNext() {
40+
if (closed) {
41+
throw new IllegalStateException("Already closed");
42+
}
43+
return index < size;
44+
}
45+
46+
@Override
47+
public Integer next() {
48+
if (closed) {
49+
throw new IllegalStateException("Already closed");
50+
}
51+
return index++;
52+
}
53+
54+
@Override
55+
public void close() {
56+
closed = true;
57+
}
58+
}
59+
60+
@Test
61+
public void testDrainThenClosedBehavior() {
62+
SynchronizedCloseableIterator<Integer> it =
63+
new SynchronizedCloseableIterator<>(new AlreadyClosedIterator(3));
64+
int count = 0;
65+
while (it.hasNext()) {
66+
it.next();
67+
count++;
68+
}
69+
Assertions.assertEquals(3, count);
70+
71+
Assertions.assertDoesNotThrow(it::close);
72+
Assertions.assertDoesNotThrow(it::close); // close is idempotent
73+
Assertions.assertFalse(it.hasNext()); // no reopen / no throw after close
74+
Assertions.assertThrows(NoSuchElementException.class, it::next);
75+
}
76+
77+
// After close, hasNext() reports exhausted even if an element was prefetched, so a closed
78+
// source never advertises another batch; an already-prefetched element is still drainable.
79+
@Test
80+
public void testClosedReportsExhaustedAfterPrefetch() {
81+
SynchronizedCloseableIterator<Integer> it =
82+
new SynchronizedCloseableIterator<>(new AlreadyClosedIterator(5));
83+
Assertions.assertTrue(it.hasNext()); // prefetches and buffers one element
84+
Assertions.assertDoesNotThrow(it::close);
85+
Assertions.assertFalse(it.hasNext()); // closed -> exhausted, does not advertise the buffer
86+
Assertions.assertDoesNotThrow(it::next); // an in-flight next() still returns the buffered element
87+
}
88+
89+
// Consuming on one thread while another closes must never surface
90+
// IllegalStateException("Already closed") from the delegate.
91+
@Test
92+
public void testConcurrentConsumeAndClose() throws InterruptedException {
93+
for (int round = 0; round < 2000; round++) {
94+
SynchronizedCloseableIterator<Integer> it =
95+
new SynchronizedCloseableIterator<>(new AlreadyClosedIterator(1000));
96+
AtomicReference<Throwable> failure = new AtomicReference<>();
97+
CountDownLatch start = new CountDownLatch(1);
98+
99+
Thread consumer = new Thread(() -> {
100+
try {
101+
start.await();
102+
while (it.hasNext()) {
103+
it.next();
104+
}
105+
} catch (Throwable t) {
106+
failure.set(t);
107+
}
108+
});
109+
Thread closer = new Thread(() -> {
110+
try {
111+
start.await();
112+
it.close();
113+
} catch (Throwable t) {
114+
failure.set(t);
115+
}
116+
});
117+
118+
consumer.start();
119+
closer.start();
120+
start.countDown();
121+
consumer.join();
122+
closer.join();
123+
124+
if (failure.get() != null) {
125+
Assertions.fail("concurrent consume/close threw: " + failure.get());
126+
}
127+
}
128+
}
129+
}

0 commit comments

Comments
 (0)