JingsongLi commented on code in PR #6426:
URL: https://github.com/apache/paimon/pull/6426#discussion_r3459214616


##########
paimon-core/src/main/java/org/apache/paimon/index/PartitionIndex.java:
##########
@@ -150,6 +189,145 @@ public static PartitionIndex loadIndex(
                 throw new UncheckedIOException(e);
             }
         }
-        return new PartitionIndex(mapBuilder.build(), buckets, 
targetBucketRowNumber);
+        return new PartitionIndex(
+                mapBuilder.build(), buckets, targetBucketRowNumber, 
indexFileHandler, partition);
+    }
+
+    private boolean shouldRefreshWhenBucketNearFull(
+            long currentBucketRowCount,
+            long targetBucketRowNumber,
+            int minEmptyBucketsBeforeAsyncCheck) {
+        // Only refresh if:
+        // 1. Feature is enabled (minEmptyBucketsBeforeAsyncCheck != -1)
+        // 2. Current bucket is approaching its target capacity
+        // When bucket reaches (targetBucketRowNumber - 
minEmptyBucketsBeforeAsyncCheck),
+        // trigger refresh to find buckets freed by compaction
+        if (minEmptyBucketsBeforeAsyncCheck == -1) {
+            return false;
+        }
+
+        long threshold = targetBucketRowNumber - 
minEmptyBucketsBeforeAsyncCheck;
+        return currentBucketRowCount >= threshold;
+    }
+
+    private boolean isReachedTheMinRefreshInterval(final Duration duration) {
+        return lastRefreshTime == null || 
Instant.now().isAfter(lastRefreshTime.plus(duration));
+    }
+
+    /**
+     * Cancel any ongoing refresh operation. Should be called when this 
PartitionIndex is no longer
+     * needed (e.g., during cleanup in prepareCommit).
+     */
+    public void cancelOngoingRefresh() {
+        if (refreshFuture != null && !refreshFuture.isDone()) {
+            refreshFuture.cancel(false);
+            if (LOG.isDebugEnabled()) {
+                LOG.debug("Cancelled ongoing refresh for partition {}", 
partition);
+            }
+        }
+    }
+
+    private void refreshBucketsFromDisk(IntPredicate bucketFilter) {
+        // Only start refresh if not already in progress
+        if (refreshFuture == null || refreshFuture.isDone()) {
+            if (LOG.isDebugEnabled()) {
+                LOG.debug(
+                        "Triggering async refresh of bucket information for 
partition {}. "
+                                + "Current non-full buckets: {}",
+                        partition,
+                        nonFullBucketInformation.size());
+            }
+
+            // Reuse the shared FileOperationThreadPool instead of creating a 
dedicated
+            // executor: refresh is an I/O-bound file operation (scanning 
index manifest
+            // entries) and the shared pool already exists for this purpose. 
This avoids
+            // duplicating thread-pool resources and aligns with how other 
file-scan paths
+            // in Paimon (FileDeletionBase, TableCommitImpl, 
ListUnexistingFiles) work.
+            refreshFuture =
+                    CompletableFuture.runAsync(
+                                    () -> {
+                                        try {
+                                            List<IndexManifestEntry> files =
+                                                    
indexFileHandler.scanEntries(
+                                                            HASH_INDEX, 
partition);
+
+                                            // Use parallel stream to scan 
multiple files
+                                            // concurrently
+                                            // This reduces latency when 
dealing with many buckets.
+                                            // Apply bucketFilter so we only 
surface buckets owned
+                                            // by this assigner; otherwise 
multiple assigners would
+                                            // race for buckets they do not 
own.
+                                            Map<Integer, Long> tempBucketInfo =
+                                                    files.parallelStream()
+                                                            .filter(
+                                                                    file ->
+                                                                            
bucketFilter.test(
+                                                                               
     file.bucket()))
+                                                            .filter(
+                                                                    file ->
+                                                                            
file.indexFile()
+                                                                               
             .rowCount()
+                                                                               
     < targetBucketRowNumber)
+                                                            .collect(
+                                                                    
Collectors.toMap(
+                                                                            
IndexManifestEntry
+                                                                               
     ::bucket,
+                                                                            
file ->
+                                                                               
     file.indexFile()
+                                                                               
             .rowCount(),
+                                                                            
(existing,
+                                                                               
     replacement) ->
+                                                                               
     existing));
+
+                                            // Use putIfAbsent to avoid race 
conditions
+                                            int newBucketsFound = 0;
+                                            for (Map.Entry<Integer, Long> 
entry :
+                                                    tempBucketInfo.entrySet()) 
{
+                                                Long previous =
+                                                        
nonFullBucketInformation.putIfAbsent(

Review Comment:
   This still misses the main "freed before saturation" case. If the partition 
was already loaded with a bucket at, say, 4/5 rows and compaction rewrites the 
hash index down to 1 row before the assigner removes that bucket from 
`nonFullBucketInformation`, the async refresh will scan the new 1-row index but 
`putIfAbsent` leaves the existing 4-row value untouched. `lastRefreshTime` is 
then advanced, so with the default 24h interval the assigner keeps the stale 
near-full count, removes the bucket after one more assignment, and creates 
another bucket instead of using the freed capacity. The refresh merge needs to 
handle already-present buckets too (while preserving any uncommitted in-memory 
increments), not only absent ones.



##########
paimon-core/src/test/java/org/apache/paimon/index/HashBucketAssignerTest.java:
##########
@@ -363,4 +368,334 @@ public void testIndexEliminate() throws IOException {
         assigner.prepareCommit(3);
         assertThat(assigner.currentPartitions()).isEmpty();
     }
+
+    /**
+     * Test that bucket refresh is triggered when a bucket approaches target 
capacity, that buckets
+     * newly added on disk become discoverable by subsequent assignments, AND 
that the refresh
+     * respects assigner ownership (only buckets owned by this assigner are 
surfaced).
+     *
+     * <p>Setup uses two assigners: assigner 0 owns even buckets (0, 2, ...), 
assigner 1 owns odd
+     * buckets (1, 3, ...). Without the fix:
+     *
+     * <ul>
+     *   <li>The async refresh would never run, so bucket 2 (added after load) 
would stay invisible.
+     *   <li>Even if the refresh ran, it would surface bucket 1 (owned by 
assigner 1) into assigner
+     *       0's in-memory map, breaking the ownership invariant.
+     * </ul>
+     */
+    @Test
+    public void testRefreshTriggeredWhenBucketNearFull() throws IOException {
+        // Initial on-disk state seen by assigner 0 at load time: only bucket 
0 (its own) with
+        // 2 rows. Bucket 1 also exists on disk but is owned by assigner 1.
+        commit.commit(
+                0,
+                Arrays.asList(
+                        createCommitMessage(
+                                row(1),
+                                0,
+                                2,
+                                fileHandler.hashIndex(row(1), 0).write(new 
int[] {0, 2})),
+                        createCommitMessage(
+                                row(1),
+                                1,
+                                2,
+                                fileHandler.hashIndex(row(1), 1).write(new 
int[] {1, 3}))));
+
+        // numChannels=2, numAssigners=2, assignId=0 -> assigner 0 owns even 
buckets only.
+        // targetBucketRowNumber=5, threshold=2 -> refresh triggers when a 
bucket reaches 3 rows.
+        HashBucketAssigner assigner =
+                new HashBucketAssigner(
+                        table.snapshotManager(),
+                        commitUser,
+                        fileHandler,
+                        2,
+                        2,
+                        0,
+                        5,
+                        -1,
+                        2,
+                        Duration.ofMillis(1));
+
+        // After the assigner has loaded, simulate another writer (or 
compaction) committing a
+        // new even bucket (2) that this assigner does not yet know about. We 
also commit
+        // additional rows to bucket 1 (owned by assigner 1) to make sure the 
refresh has
+        // something to filter out.
+        commit.commit(

Review Comment:
   This setup does not actually commit after the assigner has loaded this 
partition. `HashBucketAssigner` creates the `PartitionIndex` lazily on the 
first `assign`, which happens below, so bucket 2 is loaded synchronously by 
`loadIndex` from this commit rather than discovered by the async refresh. That 
leaves the regression untested (and masks stale-count cases like the one 
above). Please force an assignment/load before this commit, then commit the 
refreshed index and assert that the bucket appears only after the async refresh.



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

Reply via email to