Skip to content

Commit f3ea766

Browse files
Le0shyclaude
authored andcommitted
[ASTERIXDB-3760][RT][STO] Handlers of vtree build on dimension mismatch
- user model changes: yes - storage format changes: no - interface changes: yes Details: By design, a vector index builds only over the embeddings whose dimension matches the DDL and skips the rest, so a build is implicitly partial. Each stage applied that rule on its own, which left the runtime exception handling tangled: before this change, when no vector matched, CREATE INDEX reported success while the bulk load dropped every record and ANN queries on the finished index returned no rows; when only some matched, training ran on vectors the index would never hold, one could become a centroid, their components shaped the quantization constants, and a vector longer than the declared dimension threw ArrayIndexOutOfBoundsException in the distance loop. Fix: apply the rule at every stage that consumes a sampled vector, and reject a build that would leave a partition with nothing to index. The rejection is raised in the first job, before any training work, and names both the declared and the observed dimension. - Training: test the dimension wherever a sampled vector is consumed, and seed k-means from the first indexable one. - Quantization: skip non-indexable vectors so the constants describe the vectors the index will actually hold, and reject a partition whose whole sample is non-indexable. - Reporting: the static-structure creator no longer builds after an upstream failure, which replaced the real error with "no training vectors were found"; the empty quantization payload now names its likely causes instead of a byte count. - Metadata: drop the 384 fallback for a missing `dimension`. - Storage: bind metaFrame in end(), where takeBlock needs it, so an empty load works. takeBlock reads getMaxPage() without binding the frame, unlike takePage. Tests: operator coverage that off-dimension vectors do not shape the centroids or the constants, for the partial and empty cases, and a runtimets golden for the DDL error. Ext-ref: MB-73219 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Change-Id: I44e13a38e10e09d0c439063582b2747942476f26 Reviewed-on: https://asterix-gerrit.ics.uci.edu/c/asterixdb/+/21535 Integration-Tests: Jenkins <jenkins@fulliautomatix.ics.uci.edu> Reviewed-by: Ian Maxon <imaxon@apache.org> Tested-by: Jenkins <jenkins@fulliautomatix.ics.uci.edu>
1 parent 28e3dcf commit f3ea766

15 files changed

Lines changed: 406 additions & 20 deletions

File tree

asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/vector/VectorQueries.xml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@
6969
<expected-error>cannot be used as a vector key field</expected-error>
7070
</compilation-unit>
7171
</test-case>
72+
<test-case FilePath="vector">
73+
<!-- Either build job can report a wholly non-indexable sample: the quantization job names the
74+
declared and the found dimension, and the training job names the declared one among the causes
75+
of an empty parameter block. Which one arrives first is not deterministic, so match the phrase
76+
they share. Matching is a literal substring test under CancellationTestExecutor, so this cannot
77+
be an alternation. -->
78+
<compilation-unit name="create-index-vtree-dimension-mismatch">
79+
<output-dir compare="Text">create-index-vtree-dimension-mismatch</output-dir>
80+
<expected-error>the index declares</expected-error>
81+
<!-- Raised by a build job on an NC, so it carries no source location. -->
82+
<source-location>false</source-location>
83+
</compilation-unit>
84+
</test-case>
7285
<test-case FilePath="vector">
7386
<compilation-unit name="create-index-vtree-composite-pk">
7487
<output-dir compare="Clean-JSON">create-index-vtree-composite-pk</output-dir>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
// Negative test: the vectors in the data are not the dimension the index declares. Every stage of the
21+
// build skips a vector it cannot index, so without a guard the build "succeeds" over an empty index and
22+
// ANN queries then silently return nothing. It must fail instead, naming both dimensions.
23+
24+
DROP DATAVERSE test IF EXISTS;
25+
CREATE DATAVERSE test;
26+
USE test;
27+
28+
CREATE TYPE MovieType AS {
29+
id: int,
30+
embedding: [double]
31+
};
32+
33+
CREATE DATASET MovieSmall(MovieType) PRIMARY KEY id;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
USE test;
21+
22+
// Every embedding is 4-dimensional.
23+
INSERT INTO MovieSmall ([
24+
{"id": 1, "embedding": [0.0, 0.0, 0.0, 0.1]},
25+
{"id": 2, "embedding": [0.0, 0.0, 0.0, 0.2]},
26+
{"id": 3, "embedding": [0.0, 0.0, 0.0, 0.3]},
27+
{"id": 4, "embedding": [0.0, 0.0, 0.0, 0.4]},
28+
{"id": 5, "embedding": [0.0, 0.0, 0.0, 0.5]},
29+
{"id": 6, "embedding": [10.0, 10.0, 10.0, 0.1]},
30+
{"id": 7, "embedding": [10.0, 10.0, 10.0, 0.2]},
31+
{"id": 8, "embedding": [10.0, 10.0, 10.0, 0.3]},
32+
{"id": 9, "embedding": [10.0, 10.0, 10.0, 0.4]},
33+
{"id": 10, "embedding": [10.0, 10.0, 10.0, 0.5]}
34+
]);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
ANALYZE DATASET test.MovieSmall WITH {"sample-seed": 1000};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
USE test;
21+
22+
SET `compiler.vector.trainseed` "42";
23+
24+
// The data is 4-dimensional, so no sampled vector can be indexed under dimension 8.
25+
CREATE INDEX idx_emb
26+
ON MovieSmall(embedding VECTOR)
27+
TYPE VTREE
28+
WITH { "dimension": 8, "similarity": "euclidean", "num_clusters": 2, "train_list_fraction": 1.0 }
29+
EXCLUDE UNKNOWN KEY;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
DROP DATAVERSE test IF EXISTS;

asterixdb/asterix-metadata/src/main/java/org/apache/asterix/metadata/utils/SecondaryVectorOperationsHelper.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@ public JobSpecification buildCreationJobSpec() throws AlgebricksException {
677677
RecordDescriptor flattenedRecordDesc = new RecordDescriptor(flattenedSerDes, flattenedTypeTraits);
678678

679679
targetOp = new VectorComponentExtractorOperatorDescriptor(spec, vectorFieldEvalFactory,
680-
dataset.getPrimaryRecordDescriptor(metadataProvider), flattenedRecordDesc);
680+
dataset.getPrimaryRecordDescriptor(metadataProvider), flattenedRecordDesc, vectorDimensions);
681681
AlgebricksPartitionConstraintHelper.setPartitionConstraintInJobSpec(spec, targetOp, samplePartitionConstraint);
682682
spec.connect(new OneToOneConnectorDescriptor(spec), sourceOp, 0, targetOp, 0);
683683
sourceOp = targetOp;

asterixdb/asterix-metadata/src/main/java/org/apache/asterix/metadata/utils/VTreeResourceFactoryProvider.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,14 @@ public IResourceFactory getResourceFactory(MetadataProvider mdProvider, Dataset
9292
vectorIndexDetails.getKeyFieldNames().size(), index.getIndexType(), 1);
9393
}
9494

95-
// Extract vector dimensions from WITH clause
95+
// The index's physical dimension: it sizes the data frames and is what query vectors are checked
96+
// against. Mandatory at CREATE INDEX and at persist, so a missing value here means a corrupt record.
9697
AdmObjectNode withObjectNode = vectorIndexDetails.getWithObjectNode();
97-
int vectorDimensions = (withObjectNode != null) ? withObjectNode.getOptionalInt(WITH_KEY_DIMENSION, 384) : 384;
98+
int vectorDimensions = (withObjectNode != null) ? withObjectNode.getOptionalInt(WITH_KEY_DIMENSION, -1) : -1;
99+
if (vectorDimensions <= 0) {
100+
throw new CompilationException(ErrorCode.COMPILATION_VECTOR_INDEX_CREATION_FAILED,
101+
"Index " + index.getIndexName() + " is missing the required positive `dimension` parameter");
102+
}
98103

99104
// Get INCLUDE fields count from index details (needed by factory)
100105
List<List<String>> includeFieldNames = vectorIndexDetails.getIncludeFieldNames();

asterixdb/asterix-runtime/src/main/java/org/apache/asterix/runtime/operators/HierarchicalKMeansPlusPlusCentroidsOperatorDescriptor.java

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,15 @@ public HierarchicalKMeansPlusPlusCentroidsOperatorDescriptor(IOperatorDescriptor
178178
this.similarityMetric = similarityMetric;
179179
}
180180

181+
/**
182+
* Whether the index can hold this vector. The bulk load applies the same rule, so one that fails here is
183+
* never indexed, and must not shape the centroids either: not as a seed, candidate, padding or mean.
184+
*/
185+
@AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.ASSISTED)
186+
private boolean hasIndexDimension(double[] point) {
187+
return point.length == vectorDimension;
188+
}
189+
181190
@Override
182191
public void contributeActivities(IActivityGraphBuilder builder) {
183192
// Activity 1: Store centroids and materialize data
@@ -332,7 +341,7 @@ public void initialize() throws HyracksDataException {
332341

333342
} catch (Throwable e) {
334343
writer.fail();
335-
throw new RuntimeException(e);
344+
throw HyracksDataException.create(e);
336345
} finally {
337346
// Close the LAST reader opened by any nested reset, not just the first one.
338347
closeCurrentSampleReader();
@@ -395,6 +404,12 @@ private ClusteringResult performKMeansParallel(IHyracksTaskContext ctx, Generate
395404
int firstIdx = rand.nextInt(totalTupleCount);
396405
double[] firstCentroid = getPointAtIndex(in, fta, tuple, eval, inputVal, listAccessorConstant,
397406
kMeansUtils, firstIdx, ctx);
407+
if (firstCentroid != null && !hasIndexDimension(firstCentroid)) {
408+
// Every pass below skips non-indexable vectors, so seeding with one would train
409+
// around a centroid nothing can be assigned to.
410+
firstCentroid = seedFromFirstIndexableVector(ctx, fta, tuple, eval, inputVal,
411+
listAccessorConstant, kMeansUtils, partition);
412+
}
398413
if (firstCentroid == null) {
399414
return new ClusteringResult(new ArrayList<>(), assignments);
400415
}
@@ -432,10 +447,11 @@ private ClusteringResult performKMeansParallel(IHyracksTaskContext ctx, Generate
432447
listAccessorConstant.reset(inputVal.getByteArray(), inputVal.getStartOffset());
433448
try {
434449
double[] point = kMeansUtils.createPrimitveList(listAccessorConstant);
435-
// Compute D(x) = min distance to current centers
436-
if (point.length != vectorDimension) {
450+
if (!hasIndexDimension(point)) {
451+
tempIdx++;
437452
continue;
438453
}
454+
// Compute D(x) = min distance to current centers
439455
double minDist = Double.POSITIVE_INFINITY;
440456
for (double[] center : currentCenters) {
441457
double dist = distanceFunction.apply(point, center);
@@ -478,6 +494,10 @@ private ClusteringResult performKMeansParallel(IHyracksTaskContext ctx, Generate
478494
listAccessorConstant.reset(inputVal.getByteArray(), inputVal.getStartOffset());
479495
try {
480496
double[] point = kMeansUtils.createPrimitveList(listAccessorConstant);
497+
if (!hasIndexDimension(point)) {
498+
currentIdx++;
499+
continue;
500+
}
481501

482502
// RECOMPUTE D(x) (no storage from pass 1)
483503
double minDist = Double.POSITIVE_INFINITY;
@@ -536,6 +556,10 @@ private ClusteringResult performKMeansParallel(IHyracksTaskContext ctx, Generate
536556
listAccessorConstant.reset(inputVal.getByteArray(), inputVal.getStartOffset());
537557
try {
538558
double[] point = kMeansUtils.createPrimitveList(listAccessorConstant);
559+
if (!hasIndexDimension(point)) {
560+
weightIdx++;
561+
continue;
562+
}
539563

540564
// Find nearest candidate (recompute distance)
541565
double minDist = Double.POSITIVE_INFINITY;
@@ -623,7 +647,8 @@ private ClusteringResult performKMeansParallel(IHyracksTaskContext ctx, Generate
623647
List<double[]> additionalPoints = getPointsAtSortedIndices(in, fta, tuple, eval, inputVal,
624648
listAccessorConstant, kMeansUtils, additionalIndices, ctx);
625649
for (double[] additionalPoint : additionalPoints) {
626-
if (additionalPoint != null) {
650+
// Non-indexable vectors must not become centroids either.
651+
if (additionalPoint != null && hasIndexDimension(additionalPoint)) {
627652
// Avoid duplicates - only add if not too close to existing centroids
628653
boolean isDuplicate = false;
629654
for (double[] existingCentroid : centroids) {
@@ -686,6 +711,10 @@ private ClusteringResult performKMeansParallel(IHyracksTaskContext ctx, Generate
686711
listAccessorConstant.reset(inputVal.getByteArray(), inputVal.getStartOffset());
687712
try {
688713
double[] point = kMeansUtils.createPrimitveList(listAccessorConstant);
714+
if (!hasIndexDimension(point)) {
715+
currentIdx++;
716+
continue;
717+
}
689718

690719
// Find closest centroid
691720
double minDist = Double.POSITIVE_INFINITY;
@@ -732,6 +761,10 @@ private ClusteringResult performKMeansParallel(IHyracksTaskContext ctx, Generate
732761
listAccessorConstant.reset(inputVal.getByteArray(), inputVal.getStartOffset());
733762
try {
734763
double[] point = kMeansUtils.createPrimitveList(listAccessorConstant);
764+
if (!hasIndexDimension(point)) {
765+
currentIdx++;
766+
continue;
767+
}
735768

736769
int centroidIdx = assignments[currentIdx];
737770
for (int d = 0; d < point.length; d++) {
@@ -1012,6 +1045,38 @@ private int selectWeightedRandomIndex(List<double[]> candidates, int[] weights,
10121045
return candidates.size() - 1; // Fallback
10131046
}
10141047

1048+
/**
1049+
* Returns the first indexable vector, to seed k-means when the random pick was not one, or
1050+
* {@code null} if this partition holds none. The first build job already rejects that case, so
1051+
* it is unreachable here for a dimension mismatch.
1052+
*/
1053+
@AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.ASSISTED)
1054+
private double[] seedFromFirstIndexableVector(IHyracksTaskContext ctx, FrameTupleAccessor fta,
1055+
FrameTupleReference tuple, IScalarEvaluator eval, IPointable inputVal,
1056+
ListAccessor listAccessor, KMeansUtils kMeansUtils, int partition)
1057+
throws HyracksDataException, IOException {
1058+
GeneratedRunFileReader reader = resetRunFileReader(ctx, sampleUUID, partition);
1059+
VSizeFrame frame = new VSizeFrame(ctx);
1060+
while (reader.nextFrame(frame)) {
1061+
fta.reset(frame.getBuffer());
1062+
int tupleCount = fta.getTupleCount();
1063+
for (int i = 0; i < tupleCount; i++) {
1064+
tuple.reset(fta, i);
1065+
eval.evaluate(tuple, inputVal);
1066+
if (!ATYPETAGDESERIALIZER.deserialize(inputVal.getByteArray()[inputVal.getStartOffset()])
1067+
.isListType()) {
1068+
continue;
1069+
}
1070+
listAccessor.reset(inputVal.getByteArray(), inputVal.getStartOffset());
1071+
double[] point = kMeansUtils.createPrimitveList(listAccessor);
1072+
if (hasIndexDimension(point)) {
1073+
return point;
1074+
}
1075+
}
1076+
}
1077+
return null;
1078+
}
1079+
10151080
/**
10161081
* Get a specific point by index from the run file.
10171082
*/

asterixdb/asterix-runtime/src/main/java/org/apache/asterix/runtime/operators/VTreeStaticStructureCreatorOperatorDescriptor.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ public IOperatorNodePushable createPushRuntime(final IHyracksTaskContext ctx,
145145
private int sampleCount = 0;
146146
private boolean quantizationParamsLoaded = false;
147147

148+
/** Set on producer failure, so {@code close()} does not build over a truncated stream. */
149+
@AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.ASSISTED)
150+
private boolean upstreamFailed = false;
151+
148152
// Get all storage partitions for this compute partition
149153
private final int[] storagePartitions = partitionsMap[partition];
150154

@@ -250,7 +254,7 @@ public void nextFrame(ByteBuffer buffer) throws HyracksDataException {
250254

251255
@Override
252256
public void fail() throws HyracksDataException {
253-
// no op
257+
upstreamFailed = true;
254258
}
255259

256260
private void processTuple(ITupleReference tuple) throws HyracksDataException {
@@ -440,7 +444,11 @@ private ITupleReference createLeafTupleWithQuantization(int centroidId, double[]
440444

441445
@Override
442446
public void close() throws HyracksDataException {
443-
447+
if (upstreamFailed) {
448+
// Only a prefix of the centroids arrived, if any. Building on that would fail here
449+
// and replace the producer's real error.
450+
return;
451+
}
444452
try {
445453
createStaticStructure();
446454
} finally {

0 commit comments

Comments
 (0)