You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@asterixdb.apache.org by AsterixDB Code Review <do...@asterix-gerrit.ics.uci.edu> on 2026/09/17 10:48:19 UTC

[L] Change in asterixdb[master]: [ASTERIXDB-3863][RT][STO] Emit VTree centroids grouped by parent

From Hongyu Shi <le...@gmail.com>:

Hongyu Shi has uploaded this change for review. ( https://asterix-gerrit.ics.uci.edu/c/asterixdb/+/21816?usp=email )


Change subject: [ASTERIXDB-3863][RT][STO] Emit VTree centroids grouped by parent
......................................................................

[ASTERIXDB-3863][RT][STO] Emit VTree centroids grouped by parent

- user model changes: no
- storage format changes: no
- interface changes: yes

Details:
The VTree training operator does not emit centroid tuples in the order
the static-structure builder's contract requires: each level grouped by
parent, since the builder attaches children to parents by position and
never reads a tuple's parent id. The k-means operator emits each
level's tuples without grouping them. Most leaves therefore sit under
an interior centroid that is not their parent.

- The emitter groups each level's tuples by parent before emitting
  them and renumbers centroid ids and parent references to match the
  new order.
- The search cursor takes its frame factories at construction and keeps
  one frame of each kind instead of building new ones for every page it
  reads.

Co-Authored-By: Claude <no...@anthropic.com>
Change-Id: Ia064148455c722b07f234c762916505417d8c3d0
---
M asterixdb/asterix-runtime/src/main/java/org/apache/asterix/runtime/operators/HierarchicalKMeansPlusPlusCentroidsOperatorDescriptor.java
M asterixdb/asterix-runtime/src/main/java/org/apache/asterix/runtime/operators/VTreeStaticStructureCreatorOperatorDescriptor.java
M asterixdb/asterix-runtime/src/test/java/org/apache/asterix/runtime/operators/HierarchicalKMeansPlusPlusCentroidsOperatorDescriptorTest.java
M hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTree.java
M hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTreeSearchCursor.java
5 files changed, 172 insertions(+), 115 deletions(-)



  git pull ssh://asterix-gerrit.ics.uci.edu:29418/asterixdb refs/changes/16/21816/1

diff --git a/asterixdb/asterix-runtime/src/main/java/org/apache/asterix/runtime/operators/HierarchicalKMeansPlusPlusCentroidsOperatorDescriptor.java b/asterixdb/asterix-runtime/src/main/java/org/apache/asterix/runtime/operators/HierarchicalKMeansPlusPlusCentroidsOperatorDescriptor.java
index 6e1dd77..91cd5c0 100644
--- a/asterixdb/asterix-runtime/src/main/java/org/apache/asterix/runtime/operators/HierarchicalKMeansPlusPlusCentroidsOperatorDescriptor.java
+++ b/asterixdb/asterix-runtime/src/main/java/org/apache/asterix/runtime/operators/HierarchicalKMeansPlusPlusCentroidsOperatorDescriptor.java
@@ -1565,6 +1565,17 @@
                 idOffset[L] = idOffset[L + 1] + sizeAbove;
             }
 
+            int[][] parentByLevel = new int[maxLevel + 1][];
+            for (int L = 0; L <= maxLevel; L++) {
+                List<CentroidInfo> levelInfo = levelCentroids.get(L);
+                int size = levelInfo == null ? 0 : levelInfo.size();
+                parentByLevel[L] = new int[size];
+                for (int i = 0; i < size; i++) {
+                    parentByLevel[L][i] = levelInfo.get(i).parentClusterId;
+                }
+            }
+            LevelOrder levelOrder = LevelOrder.groupByParent(parentByLevel);
+
             // Walk levels bottom-up: levelCentroids key 0 (leaves) → key maxLevel (root).
             // The tuple's treeLevel field keeps the existing convention: root = 0, leaf = maxLevel.
             for (int L = 0; L <= maxLevel; L++) {
@@ -1573,11 +1584,12 @@
                     continue;
                 }
                 int treeLevel = maxLevel - L;
-                int globalCentroidId = idOffset[L];
-                for (CentroidInfo centroid : levelInfo) {
-                    createHierarchicalTuple(treeLevel, globalCentroidId, centroid.parentClusterId, centroid.embedding,
-                            appender, writer, ctx);
-                    globalCentroidId++;
+                int[] order = levelOrder.order[L];
+                for (int k = 0; k < order.length; k++) {
+                    CentroidInfo centroid = levelInfo.get(order[k]);
+                    int parent = L == maxLevel ? -1 : levelOrder.position[L + 1][centroid.parentClusterId];
+                    createHierarchicalTuple(treeLevel, idOffset[L] + k, parent, centroid.embedding, appender, writer,
+                            ctx);
                 }
             }
         }
@@ -1701,4 +1713,52 @@
         }
     }
 
+    /**
+     * The emission order of every level, grouped by parent. The static-structure builder attaches each
+     * level's runs of children to the parents by position, so a level must be emitted with the children
+     * of parent 0 first, then parent 1, and so on, where "parent k" means the k-th emitted tuple of the
+     * level above. Ties keep the k-means order.
+     */
+    static final class LevelOrder {
+        /** {@code order[L][k]} is the list index of the k-th emitted tuple of level {@code L}. */
+        final int[][] order;
+        /** {@code position[L][i]} is the emission position of list index {@code i} of level {@code L}. */
+        final int[][] position;
+
+        private LevelOrder(int[][] order, int[][] position) {
+            this.order = order;
+            this.position = position;
+        }
+
+        /**
+         * @param parentByLevel {@code parentByLevel[L][i]} is the list index in level {@code L + 1} of the
+         *                      parent of tuple {@code i} of level {@code L}; the last level is the root and
+         *                      its entries are ignored.
+         */
+        static LevelOrder groupByParent(int[][] parentByLevel) {
+            int levels = parentByLevel.length;
+            int[][] order = new int[levels][];
+            int[][] position = new int[levels][];
+            for (int L = levels - 1; L >= 0; L--) {
+                int size = parentByLevel[L].length;
+                Integer[] indices = new Integer[size];
+                for (int i = 0; i < size; i++) {
+                    indices[i] = i;
+                }
+                if (L < levels - 1) {
+                    int[] parentPosition = position[L + 1];
+                    int[] parentOf = parentByLevel[L];
+                    Arrays.sort(indices,
+                            (a, b) -> Integer.compare(parentPosition[parentOf[a]], parentPosition[parentOf[b]]));
+                }
+                order[L] = new int[size];
+                position[L] = new int[size];
+                for (int k = 0; k < size; k++) {
+                    order[L][k] = indices[k];
+                    position[L][indices[k]] = k;
+                }
+            }
+            return new LevelOrder(order, position);
+        }
+    }
 }
diff --git a/asterixdb/asterix-runtime/src/main/java/org/apache/asterix/runtime/operators/VTreeStaticStructureCreatorOperatorDescriptor.java b/asterixdb/asterix-runtime/src/main/java/org/apache/asterix/runtime/operators/VTreeStaticStructureCreatorOperatorDescriptor.java
index f4ef006..ed535a5 100644
--- a/asterixdb/asterix-runtime/src/main/java/org/apache/asterix/runtime/operators/VTreeStaticStructureCreatorOperatorDescriptor.java
+++ b/asterixdb/asterix-runtime/src/main/java/org/apache/asterix/runtime/operators/VTreeStaticStructureCreatorOperatorDescriptor.java
@@ -137,6 +137,7 @@
                 private IPointable clusterIdVal;
                 private IPointable centroidIdVal;
                 private Map<Integer, Integer> levelDistribution = null;
+                private final Map<Integer, Integer> lastParentByLevel = new HashMap<>();
                 private Map<String, Map<Integer, Integer>> clusterDistribution = null;
 
                 // Quantization-related instance variables
@@ -283,6 +284,7 @@
                         }
 
                         levelDistribution.put(level, levelDistribution.getOrDefault(level, 0) + 1);
+                        checkGroupedByParent(level, clusterId, lastParentByLevel);
 
                         String levelKey = "Level_" + level;
                         Map<Integer, Integer> levelClusters =
@@ -691,4 +693,21 @@
         }
     }
 
+    /**
+     * The static-structure builder attaches each level's runs of children to the parents by position, so
+     * the tuples of a level must arrive grouped by parent in ascending parent order. Root tuples have no
+     * parent (-1) and are not checked.
+     */
+    static void checkGroupedByParent(int level, int parentClusterId, Map<Integer, Integer> lastParentByLevel)
+            throws HyracksDataException {
+        if (parentClusterId < 0) {
+            return;
+        }
+        Integer last = lastParentByLevel.put(level, parentClusterId);
+        // A tripwire on the producer's contract; the builder itself cannot check it.
+        if (last != null && parentClusterId < last) {
+            throw HyracksDataException.create(ILLEGAL_STATE, "Centroid tuples of level " + level
+                    + " are not grouped by parent: parent " + parentClusterId + " arrived after parent " + last);
+        }
+    }
 }
diff --git a/asterixdb/asterix-runtime/src/test/java/org/apache/asterix/runtime/operators/HierarchicalKMeansPlusPlusCentroidsOperatorDescriptorTest.java b/asterixdb/asterix-runtime/src/test/java/org/apache/asterix/runtime/operators/HierarchicalKMeansPlusPlusCentroidsOperatorDescriptorTest.java
index 60bfe38..d665295 100644
--- a/asterixdb/asterix-runtime/src/test/java/org/apache/asterix/runtime/operators/HierarchicalKMeansPlusPlusCentroidsOperatorDescriptorTest.java
+++ b/asterixdb/asterix-runtime/src/test/java/org/apache/asterix/runtime/operators/HierarchicalKMeansPlusPlusCentroidsOperatorDescriptorTest.java
@@ -255,6 +255,54 @@
         }
     }
 
+    /**
+     * The static-structure creator derives per-parent counts from the stream and the builder files leaves
+     * into parents positionally, so within a level the tuples must arrive grouped by parent, parents in
+     * ascending order.
+     */
+    @Test
+    public void testEachLevelArrivesGroupedByParent() throws Exception {
+        List<CentroidTuple> tuples = parseAll(runOperator(7L, 36, 512, twoClusterVectors(40)));
+        Map<Integer, List<Integer>> parentsByLevel = new HashMap<>();
+        for (CentroidTuple t : tuples) {
+            if (t.treeLevel > 0) {
+                parentsByLevel.computeIfAbsent(t.treeLevel, k -> new java.util.ArrayList<>()).add(t.parentClusterId);
+            }
+        }
+        Assert.assertFalse("expected at least one non-root level", parentsByLevel.isEmpty());
+        for (Map.Entry<Integer, List<Integer>> e : parentsByLevel.entrySet()) {
+            List<Integer> parents = e.getValue();
+            for (int i = 1; i < parents.size(); i++) {
+                Assert.assertTrue(
+                        "level " + e.getKey() + " tuples arrive with parents " + parents + ", not grouped by parent",
+                        parents.get(i) >= parents.get(i - 1));
+            }
+        }
+    }
+
+    /**
+     * Reordering a level moves its tuples, so the level below must reference the parents' emission
+     * positions, not their k-means indices. Three levels: a root of two, a middle of three, six leaves.
+     */
+    @Test
+    public void testGroupByParentRemapsParentsToEmissionPositions() {
+        int[][] parentByLevel = { { 2, 0, 1, 2, 0, 1 }, { 1, 0, 1 }, { -1, -1 } };
+        HierarchicalKMeansPlusPlusCentroidsOperatorDescriptor.LevelOrder levelOrder =
+                HierarchicalKMeansPlusPlusCentroidsOperatorDescriptor.LevelOrder.groupByParent(parentByLevel);
+
+        Assert.assertArrayEquals(new int[] { 0, 1 }, levelOrder.order[2]);
+        // Middle: the child of root 0 first, then the two children of root 1 in k-means order.
+        Assert.assertArrayEquals(new int[] { 1, 0, 2 }, levelOrder.order[1]);
+        Assert.assertArrayEquals(new int[] { 1, 0, 2 }, levelOrder.position[1]);
+        // Leaves: grouped by the middle tuples' emission positions (old 1 -> 0, old 0 -> 1, old 2 -> 2).
+        Assert.assertArrayEquals(new int[] { 2, 5, 1, 4, 0, 3 }, levelOrder.order[0]);
+        int[] emittedParents = new int[6];
+        for (int k = 0; k < 6; k++) {
+            emittedParents[k] = levelOrder.position[1][parentByLevel[0][levelOrder.order[0][k]]];
+        }
+        Assert.assertArrayEquals(new int[] { 0, 0, 1, 1, 2, 2 }, emittedParents);
+    }
+
     @Test
     public void testClusteringSanityTwoSeparatedGroups() throws Exception {
         List<double[]> vectors = twoClusterVectors(20);
diff --git a/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTree.java b/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTree.java
index 3fde481..afa4749 100644
--- a/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTree.java
+++ b/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTree.java
@@ -672,7 +672,8 @@
          * @return Configured search cursor
          */
         public IIndexCursor createSearchCursor(boolean exclusive, boolean fullScanMode) throws HyracksDataException {
-            VTreeSearchCursor cursor = new VTreeSearchCursor();
+            VTreeSearchCursor cursor = new VTreeSearchCursor(tree.interiorFrameFactory, tree.leafFrameFactory,
+                    tree.dataFrameFactory, tree.metadataFrameFactory);
             configureCursor(cursor);
             cursor.setFullScanMode(fullScanMode);
             return cursor;
@@ -691,8 +692,6 @@
                 cursor.setRootPageId(tree.rootPage);
                 cursor.setDataBufferCache(tree.bufferCache, tree.getFileId());
             }
-            cursor.setFrameFactories(tree.interiorFrameFactory, tree.leafFrameFactory, tree.metadataFrameFactory,
-                    tree.dataFrameFactory);
         }
 
         public VTree getIndex() {
diff --git a/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTreeSearchCursor.java b/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTreeSearchCursor.java
index 8a1e1ae..e55d549 100644
--- a/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTreeSearchCursor.java
+++ b/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTreeSearchCursor.java
@@ -47,7 +47,7 @@
 import org.apache.hyracks.storage.common.buffercache.IBufferCache;
 import org.apache.hyracks.storage.common.buffercache.ICachedPage;
 import org.apache.hyracks.storage.common.file.BufferedFileHandle;
-import org.apache.hyracks.util.annotations.AiProvenance;
+import org.apache.logging.log4j.Level;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
@@ -63,10 +63,8 @@
     private IBufferCache bufferCache;
     private int fileId;
     private int rootPageId;
-    private ITreeIndexFrameFactory interiorFrameFactory;
-    private ITreeIndexFrameFactory leafFrameFactory;
-    private ITreeIndexFrameFactory metadataFrameFactory;
-    private ITreeIndexFrameFactory dataFrameFactory;
+    private final ITreeIndexFrameFactory interiorFrameFactory;
+    private final ITreeIndexFrameFactory leafFrameFactory;
 
     // Data access fields (for memory components: VBC; for disk: same as bufferCache)
     private IBufferCache dataBufferCache;
@@ -90,8 +88,11 @@
     private boolean isOpen;
     private ITupleReference currentTuple;
     private ICachedPage currentPage;
-    private IVTreeDataFrame dataFrame;
-    private ITreeIndexTupleReference frameTuple;
+    private final IVTreeInteriorFrame interiorFrame;
+    private final IVTreeLeafFrame leafFrame;
+    private final IVTreeDataFrame dataFrame;
+    private final IVTreeMetadataFrame metadataFrame;
+    private final ITreeIndexTupleReference frameTuple;
     /* Position in current data page (0-based, next tuple to read) */
     private int currentTupleIndex;
     /* Total tuples in current data page */
@@ -138,7 +139,15 @@
     private double[] quantizedQueryVector; // Quantized query vector (null = non-quantized)
     private IVTreeQuantizer quantizer; // Quantizer instance (null = non-quantized)
 
-    public VTreeSearchCursor() {
+    public VTreeSearchCursor(ITreeIndexFrameFactory interiorFrameFactory, ITreeIndexFrameFactory leafFrameFactory,
+            ITreeIndexFrameFactory dataFrameFactory, ITreeIndexFrameFactory metadataFrameFactory) {
+        this.interiorFrameFactory = interiorFrameFactory;
+        this.leafFrameFactory = leafFrameFactory;
+        this.interiorFrame = (IVTreeInteriorFrame) interiorFrameFactory.createFrame();
+        this.leafFrame = (IVTreeLeafFrame) leafFrameFactory.createFrame();
+        this.dataFrame = (IVTreeDataFrame) dataFrameFactory.createFrame();
+        this.metadataFrame = (IVTreeMetadataFrame) metadataFrameFactory.createFrame();
+        this.frameTuple = dataFrame.createTupleReference();
         this.isOpen = false;
         this.currentTupleIndex = 0;
         this.tupleCount = 0;
@@ -192,14 +201,6 @@
         this.rootPageId = rootPageId;
     }
 
-    public void setFrameFactories(ITreeIndexFrameFactory interiorFrameFactory, ITreeIndexFrameFactory leafFrameFactory,
-            ITreeIndexFrameFactory metadataFrameFactory, ITreeIndexFrameFactory dataFrameFactory) {
-        this.interiorFrameFactory = interiorFrameFactory;
-        this.leafFrameFactory = leafFrameFactory;
-        this.metadataFrameFactory = metadataFrameFactory;
-        this.dataFrameFactory = dataFrameFactory;
-    }
-
     public void setQueryVector(double[] queryVector) {
         this.queryVector = queryVector;
     }
@@ -380,8 +381,7 @@
             throw HyracksDataException.create(new IllegalStateException("No more tuples"));
         }
 
-        // Position on next tuple using frameTuple
-        if (this.dataFrame != null && this.frameTuple != null) {
+        if (this.currentPage != null) {
             this.frameTuple.resetByTupleIndex(this.dataFrame, currentTupleIndex);
             this.currentTuple = this.frameTuple;
         }
@@ -437,7 +437,7 @@
 
         // Step 4: Open cluster 0
         this.currentSequentialClusterIndex = 0;
-        openClusterByDirectoryPage(this.firstDirectoryPageId);
+        openClusterByDirectoryPage(this.firstDirectoryPageId, 0);
         this.clustersProbed = 1;
 
         // Create ClusterSearchResult for first cluster (for LSM layer to access)
@@ -463,7 +463,6 @@
             ICachedPage page = bufferCache.pin(BufferedFileHandle.getDiskPageId(fileId, currentPageId));
             try {
                 page.acquireReadLatch();
-                IVTreeInteriorFrame interiorFrame = createInteriorFrame();
                 interiorFrame.setPage(page);
 
                 if (interiorFrame.getLevel() == 0) {
@@ -499,7 +498,6 @@
             ICachedPage leafPage = bufferCache.pin(BufferedFileHandle.getDiskPageId(fileId, leafPageId));
             try {
                 leafPage.acquireReadLatch();
-                IVTreeLeafFrame leafFrame = createLeafFrame();
                 leafFrame.setPage(leafPage);
 
                 int tupleCount = leafFrame.getTupleCount();
@@ -528,20 +526,17 @@
      * Open a cluster by its directory (metadata) page ID.
      * Used by full-scan mode for sequential cluster iteration.
      */
-    private void openClusterByDirectoryPage(long directoryPageId) throws HyracksDataException {
+    private void openClusterByDirectoryPage(long directoryPageId, int centroidId) throws HyracksDataException {
         // Log previous cluster summary if we have data
         this.targetMetadataPageId = directoryPageId;
 
-        // Guard: directoryPageId=-1 means empty cluster (no data assigned during bulk loading)
+        // A bulk load leaves the directory pointer unassigned on a leaf cluster that received no record.
         if (directoryPageId == -1) {
             if (!fullScanMode) {
-                // In query mode a -1 sentinel usually means the directory page could not be
-                // resolved in this component (e.g., a corrupt/wrong component root made the
-                // cluster unreachable) — flag it instead of silently yielding an empty cluster.
-                // Merge (full-scan) mode legitimately sees -1 for empty clusters, so stay quiet there.
                 emptyClustersInQueryMode++;
-                LOGGER.warn("VTreeSearchCursor: -1 directory page sentinel in query mode (fileId={}, rootPageId={}); "
-                        + "treating cluster as empty", fileId, rootPageId);
+                LOGGER.log(Level.TRACE,
+                        "cluster {} has no directory page: no record was loaded into it (fileId={}, rootPageId={})",
+                        centroidId, fileId, rootPageId);
             }
             markCurrentClusterEmpty();
             return;
@@ -551,21 +546,16 @@
         ICachedPage dirPage = dataBufferCache.pin(BufferedFileHandle.getDiskPageId(dataFileId, (int) directoryPageId));
         try {
             dirPage.acquireReadLatch();
-            IVTreeMetadataFrame metadataFrame = createMetadataFrame();
             metadataFrame.setPage(dirPage);
 
             int metadataTupleCount = metadataFrame.getTupleCount();
+            // A memory component pre-allocates one directory page per cluster and a flush copies it as is,
+            // so a cluster with no inserts has a directory page with no entries.
             if (metadataTupleCount == 0) {
                 if (!fullScanMode) {
-                    // The directory page resolved but has no data-page entries. In query mode that is a
-                    // recall hole (an expected-non-empty cluster yields nothing) and a sign of an
-                    // inconsistent component; surface it consistently with the -1 case above instead of
-                    // returning empty silently.
                     emptyClustersInQueryMode++;
-                    LOGGER.warn(
-                            "VTreeSearchCursor: directory page {} has no metadata entries in query mode "
-                                    + "(fileId={}, rootPageId={}); treating cluster as empty",
-                            directoryPageId, fileId, rootPageId);
+                    LOGGER.log(Level.TRACE, "cluster {} directory page {} has no data pages (fileId={}, rootPageId={})",
+                            centroidId, directoryPageId, fileId, rootPageId);
                 }
                 markCurrentClusterEmpty();
                 return;
@@ -610,7 +600,7 @@
             }
 
             long nextDirectoryPageId = allDirectoryPageIds.get(currentSequentialClusterIndex);
-            openClusterByDirectoryPage(nextDirectoryPageId);
+            openClusterByDirectoryPage(nextDirectoryPageId, currentSequentialClusterIndex);
 
             // Create ClusterSearchResult for this sequential cluster
             // In full-scan mode, we don't have centroid info, but we have the directory page
@@ -674,7 +664,7 @@
         // getMetadataPageIdFromCluster handles both memory (centroidDirPageMap)
         // and disk (leaf page traversal) correctly.
         long localDirPageId = getMetadataPageIdFromCluster(cluster);
-        openClusterByDirectoryPage(localDirPageId);
+        openClusterByDirectoryPage(localDirPageId, cluster.centroidId);
         this.currentClusterResult = cluster;
         this.clustersProbed++;
 
@@ -718,7 +708,6 @@
                 dataBufferCache.pin(BufferedFileHandle.getDiskPageId(dataFileId, (int) targetMetadataPageId));
         try {
             metadataPage.acquireReadLatch();
-            IVTreeMetadataFrame metadataFrame = createMetadataFrame();
             metadataFrame.setPage(metadataPage);
 
             int tupleCount = metadataFrame.getTupleCount();
@@ -750,10 +739,7 @@
             page.acquireReadLatch();
             latched = true;
 
-            // Initialize data frame
-            this.dataFrame = createDataFrame();
             this.dataFrame.setPage(page);
-            this.frameTuple = this.dataFrame.createTupleReference();
             this.tupleCount = this.dataFrame.getTupleCount();
             this.currentTupleIndex = 0;
             this.currentPage = page;
@@ -773,7 +759,7 @@
      * @return true if successfully moved to next page, false if no more pages
      */
     private boolean moveToNextDataPage() throws HyracksDataException {
-        if (dataFrame == null) {
+        if (currentPage == null) {
             return false;
         }
 
@@ -813,21 +799,12 @@
     }
 
     /**
-     * Position the cursor on an empty cluster: release the page of the cluster being abandoned and drop the
-     * frame that was reading it.
-     * <p>
-     * Clearing {@code dataFrame}/{@code frameTuple} is what makes this safe. Leaving them pointing at the
-     * previous cluster's page lets the next {@code hasNext()} fall into {@link #moveToNextDataPage()}, which
-     * reads {@code dataFrame.getNextPage()} off that stale frame and silently resumes the abandoned cluster's
-     * data-page chain. That is reachable in practice because the LSM layer re-points this cursor mid-cluster
-     * during cross-component cluster synchronization ({@code openClusterByResult}), so a cluster is not
-     * necessarily drained to the end of its chain before the next one is opened.
+     * Position the cursor on an empty cluster. {@code currentPage} is the only sign of an open page, so once
+     * it is closed neither {@code hasNext()} nor {@link #moveToNextDataPage()} can follow the abandoned
+     * cluster's data-page chain through the frame that last read it.
      */
-    @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.GENERATED)
     private void markCurrentClusterEmpty() throws HyracksDataException {
         closeCurrentPage();
-        this.dataFrame = null;
-        this.frameTuple = null;
         this.currentDataPageId = -1;
         this.tupleCount = 0;
         this.currentTupleIndex = 0;
@@ -862,28 +839,6 @@
     }
 
     /**
-     * Create a metadata frame instance using the frame factory.
-     */
-    private IVTreeMetadataFrame createMetadataFrame() {
-        if (metadataFrameFactory == null) {
-            // TODO(vector-errors): uncoded IllegalStateException -> reaches the user as "Internal error".
-            throw new IllegalStateException("Metadata frame factory not set");
-        }
-        return (IVTreeMetadataFrame) metadataFrameFactory.createFrame();
-    }
-
-    /**
-     * Create a data frame instance using the frame factory.
-     */
-    private IVTreeDataFrame createDataFrame() {
-        if (dataFrameFactory == null) {
-            // TODO(vector-errors): uncoded IllegalStateException -> reaches the user as "Internal error".
-            throw new IllegalStateException("Data frame factory not set");
-        }
-        return (IVTreeDataFrame) dataFrameFactory.createFrame();
-    }
-
-    /**
      * Get metadata page ID from cluster search result.
      *
      * For memory components: uses centroidDirPageMap for O(1) lookup (the map
@@ -958,8 +913,8 @@
             try {
                 page.acquireReadLatch();
 
-                // Use leaf frame to check page level (isLeaf works on any page type)
-                IVTreeLeafFrame lf = createLeafFrame();
+                // isLeaf reads the page level, which every page type carries.
+                IVTreeLeafFrame lf = leafFrame;
                 lf.setPage(page);
 
                 if (lf.isLeaf()) {
@@ -978,8 +933,7 @@
                         }
                     }
                 } else {
-                    // Interior page: enqueue all children
-                    IVTreeInteriorFrame intFrame = createInteriorFrame();
+                    IVTreeInteriorFrame intFrame = interiorFrame;
                     intFrame.setPage(page);
 
                     int tc = intFrame.getTupleCount();
@@ -1001,33 +955,10 @@
         }
     }
 
-    /**
-     * Create frame instances.
-     */
-    private IVTreeInteriorFrame createInteriorFrame() {
-        if (interiorFrameFactory == null) {
-            // TODO(vector-errors): uncoded IllegalStateException -> reaches the user as "Internal error".
-            throw new IllegalStateException("Interior frame factory not set");
-        }
-        return (IVTreeInteriorFrame) interiorFrameFactory.createFrame();
-    }
-
-    private IVTreeLeafFrame createLeafFrame() {
-        if (leafFrameFactory == null) {
-            // TODO(vector-errors): uncoded IllegalStateException -> reaches the user as "Internal error".
-            throw new IllegalStateException("Leaf frame factory not set");
-        }
-        return (IVTreeLeafFrame) leafFrameFactory.createFrame();
-    }
-
     @Override
     protected void doClose() throws HyracksDataException {
         if (isOpen && emptyClustersInQueryMode > 0) {
-            // Aggregate recall-hole summary for this search: how many probed clusters yielded nothing in
-            // query mode. Non-zero here means candidates were missed (usually an inconsistent component).
-            LOGGER.warn(
-                    "VTreeSearchCursor: {} of {} probed clusters were empty in query mode "
-                            + "(fileId={}, rootPageId={})",
+            LOGGER.log(Level.TRACE, "{} of {} probed clusters were empty (fileId={}, rootPageId={})",
                     emptyClustersInQueryMode, clustersProbed, fileId, rootPageId);
         }
         if (isOpen) {

-- 
To view, visit https://asterix-gerrit.ics.uci.edu/c/asterixdb/+/21816?usp=email
To unsubscribe, or for help writing mail filters, visit https://asterix-gerrit.ics.uci.edu/settings?usp=email

Gerrit-MessageType: newchange
Gerrit-Project: asterixdb
Gerrit-Branch: master
Gerrit-Change-Id: Ia064148455c722b07f234c762916505417d8c3d0
Gerrit-Change-Number: 21816
Gerrit-PatchSet: 1
Gerrit-Owner: Hongyu Shi <le...@gmail.com>