This is an automated email from the ASF dual-hosted git repository.

gianm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 8ff36f7ef89 fix: safely publish granularity interval lookups (#20292)
8ff36f7ef89 is described below

commit 8ff36f7ef89bccceb682d6a46d246a16d2b561d0
Author: Frank Chen <[email protected]>
AuthorDate: Thu Sep 10 11:11:33 2026 +0800

    fix: safely publish granularity interval lookups (#20292)
---
 .../indexer/granularity/BaseGranularitySpec.java   |  20 +--
 .../granularity/BaseGranularitySpecTest.java       | 150 +++++++++++++++++++++
 2 files changed, 162 insertions(+), 8 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/indexer/granularity/BaseGranularitySpec.java
 
b/processing/src/main/java/org/apache/druid/indexer/granularity/BaseGranularitySpec.java
index f2ddc999829..74ca5fe7549 100644
--- 
a/processing/src/main/java/org/apache/druid/indexer/granularity/BaseGranularitySpec.java
+++ 
b/processing/src/main/java/org/apache/druid/indexer/granularity/BaseGranularitySpec.java
@@ -21,6 +21,8 @@ package org.apache.druid.indexer.granularity;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
 import com.google.common.collect.Iterators;
 import org.apache.druid.java.util.common.DateTimes;
 import org.apache.druid.java.util.common.granularity.Granularities;
@@ -84,19 +86,24 @@ public abstract class BaseGranularitySpec implements 
GranularitySpec
    */
   protected static class LookupIntervalBuckets
   {
-    private final Iterable<Interval> intervalIterable;
-    private final TreeSet<Interval> intervals;
+    private final Supplier<TreeSet<Interval>> intervals;
 
     /**
      * @param intervalIterable The intervals to materialize
      */
     public LookupIntervalBuckets(Iterable<Interval> intervalIterable)
     {
-      this.intervalIterable = intervalIterable;
       // The tree set will be materialized on demand (see below) to avoid 
client code
       // blowing up when constructing this data structure and when the
       // number of intervals is very large...
-      this.intervals = new TreeSet<>(Comparators.intervalsByStartThenEnd());
+      // Memoization serializes initialization and safely publishes only the 
complete tree to concurrent readers.
+      this.intervals = Suppliers.memoize(() -> {
+        final TreeSet<Interval> materialized = new 
TreeSet<>(Comparators.intervalsByStartThenEnd());
+        if (intervalIterable != null) {
+          Iterators.addAll(materialized, intervalIterable.iterator());
+        }
+        return materialized;
+      });
     }
 
     /**
@@ -132,10 +139,7 @@ public abstract class BaseGranularitySpec implements 
GranularitySpec
      */
     public TreeSet<Interval> materializedIntervals()
     {
-      if (intervalIterable != null && intervalIterable.iterator().hasNext() && 
intervals.isEmpty()) {
-        Iterators.addAll(intervals, intervalIterable.iterator());
-      }
-      return intervals;
+      return intervals.get();
     }
   }
 }
diff --git 
a/processing/src/test/java/org/apache/druid/indexer/granularity/BaseGranularitySpecTest.java
 
b/processing/src/test/java/org/apache/druid/indexer/granularity/BaseGranularitySpecTest.java
new file mode 100644
index 00000000000..62db35840ff
--- /dev/null
+++ 
b/processing/src/test/java/org/apache/druid/indexer/granularity/BaseGranularitySpecTest.java
@@ -0,0 +1,150 @@
+/*
+ * 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.druid.indexer.granularity;
+
+import com.google.common.base.Optional;
+import org.apache.druid.java.util.common.Intervals;
+import org.joda.time.Interval;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.TreeSet;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class BaseGranularitySpecTest
+{
+  @Test
+  public void testConcurrentLookupDuringMaterialization() throws Exception
+  {
+    final Interval first = Intervals.of("2026-01-01/2026-01-02");
+    final Interval second = Intervals.of("2026-01-02/2026-01-03");
+    final CountDownLatch partiallyMaterialized = new CountDownLatch(1);
+    final CountDownLatch finishMaterialization = new CountDownLatch(1);
+    final BaseGranularitySpec.LookupIntervalBuckets buckets = new 
BaseGranularitySpec.LookupIntervalBuckets(
+        () -> new Iterator<>()
+        {
+          private final Iterator<Interval> delegate = List.of(first, 
second).iterator();
+
+          @Override
+          public boolean hasNext()
+          {
+            return delegate.hasNext();
+          }
+
+          @Override
+          public Interval next()
+          {
+            final Interval interval = delegate.next();
+            if (interval.equals(second)) {
+              // The first interval has been inserted, but the second has not 
been returned to the builder.
+              partiallyMaterialized.countDown();
+              try {
+                Assertions.assertTrue(finishMaterialization.await(10, 
TimeUnit.SECONDS));
+              }
+              catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new RuntimeException(e);
+              }
+            }
+            return interval;
+          }
+        }
+    );
+    final FutureTask<TreeSet<Interval>> materialization = new 
FutureTask<>(buckets::materializedIntervals);
+    final FutureTask<Optional<Interval>> lookup = new FutureTask<>(() -> 
buckets.bucketInterval(second.getStart()));
+    final Thread builder = new Thread(materialization, "interval-builder");
+    final Thread reader = new Thread(lookup, "interval-reader");
+    builder.setDaemon(true);
+    reader.setDaemon(true);
+
+    try {
+      builder.start();
+      Assertions.assertTrue(partiallyMaterialized.await(10, TimeUnit.SECONDS));
+      reader.start();
+
+      // Wait for the reader to either contend on initialization or return the 
baseline's partial lookup.
+      // This avoids relying on a sleep to assume that the second lookup has 
run.
+      final long startNanos = System.nanoTime();
+      while (!lookup.isDone()
+             && reader.getState() != Thread.State.BLOCKED
+             && System.nanoTime() - startNanos < TimeUnit.SECONDS.toNanos(10)) 
{
+        Thread.sleep(1);
+      }
+      Assertions.assertTrue(lookup.isDone() || reader.getState() == 
Thread.State.BLOCKED, "Reader did not reach lookup");
+      finishMaterialization.countDown();
+
+      Assertions.assertEquals(Optional.of(second), lookup.get(10, 
TimeUnit.SECONDS));
+      Assertions.assertEquals(List.of(first, second), 
List.copyOf(materialization.get(10, TimeUnit.SECONDS)));
+      Assertions.assertSame(materialization.get(), 
buckets.materializedIntervals());
+    }
+    finally {
+      finishMaterialization.countDown();
+      builder.join(10_000);
+      reader.join(10_000);
+    }
+  }
+
+  @Test
+  public void testLazyMaterialization()
+  {
+    final AtomicInteger iterations = new AtomicInteger();
+    final Interval interval = Intervals.of("2026-01-01/2026-01-02");
+    final BaseGranularitySpec.LookupIntervalBuckets buckets = new 
BaseGranularitySpec.LookupIntervalBuckets(() -> {
+      iterations.incrementAndGet();
+      return List.of(interval).iterator();
+    });
+
+    Assertions.assertEquals(0, iterations.get());
+    Assertions.assertEquals(Optional.of(interval), 
buckets.bucketInterval(interval.getStart()));
+    Assertions.assertEquals(List.of(interval), 
List.copyOf(buckets.materializedIntervals()));
+    Assertions.assertEquals(interval, buckets.iterator().next());
+    Assertions.assertEquals(1, iterations.get());
+  }
+
+  @Test
+  public void testEmptyMaterializationIsCached()
+  {
+    final AtomicInteger iterations = new AtomicInteger();
+    final BaseGranularitySpec.LookupIntervalBuckets buckets = new 
BaseGranularitySpec.LookupIntervalBuckets(() -> {
+      iterations.incrementAndGet();
+      return Collections.emptyIterator();
+    });
+
+    Assertions.assertEquals(0, iterations.get());
+    final TreeSet<Interval> intervals = buckets.materializedIntervals();
+    Assertions.assertTrue(intervals.isEmpty());
+    Assertions.assertSame(intervals, buckets.materializedIntervals());
+    Assertions.assertEquals(1, iterations.get());
+  }
+
+  @Test
+  public void testNullIntervals()
+  {
+    final BaseGranularitySpec.LookupIntervalBuckets buckets = new 
BaseGranularitySpec.LookupIntervalBuckets(null);
+    Assertions.assertTrue(buckets.materializedIntervals().isEmpty());
+    Assertions.assertFalse(buckets.iterator().hasNext());
+  }
+}


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

Reply via email to