3737import com .google .gson .JsonElement ;
3838import com .google .gson .JsonObject ;
3939import com .google .gson .JsonParser ;
40+ import org .lance .schema .LanceField ;
4041
4142import java .util .ArrayList ;
4243import java .util .HashMap ;
5758 * first, before target capture and the snapshot read (fail cheap-first — a reserved name
5859 * rejected at admission depth costs no remote read), then case-only collision analysis, IF
5960 * preflight (including the two-stage {@code matches}: requested-algorithm equality plus
60- * physical-family corroboration), schema contract from the stored column name, locator
61+ * physical-family corroboration), column-lookup collision analysis, schema contract from
62+ * the stored column name, locator
6163 * normalization, deterministic properties JSON, positive-quota assertion, and only then exactly
6264 * one id allocation and the durable {@code createJob} transfer.
6365 * Every rejection before {@code createJob} leaves no job, no fence, no quota charge, no journal
@@ -147,24 +149,29 @@ static Outcome admitCreate(SnapshotLoader loader, LanceExternalCatalog catalog,
147149 }
148150 return catalogMgr .withLanceIndexAdmission (catalog , target , () -> new Outcome (null ));
149151 }
150- // 5. Schema contract v1 from the stored column name (never the raw user spelling).
152+ // 5. Fail closed when the table lookup relation cannot resolve the request column
153+ // uniquely: a dataset can hold top-level fields that differ only by case (V versus v),
154+ // and ExternalTable.getColumn returns the first hit, so admitting would journal an
155+ // arbitrary one of them. The pinned snapshot decides, never the cached table schema.
156+ rejectIfAmbiguousLookupColumn (snapshot , def .getCols ().get (0 ));
157+ // 6. Schema contract v1 from the stored column name (never the raw user spelling).
151158 String storedColumnName = storedColumnName (table , def .getCols ().get (0 ));
152159 LanceIndexSchemaContract contract =
153160 LanceSchemaContractBuilder .build (snapshot .getTopLevelFields (), storedColumnName );
154- // 6 . The fence locator is the normalized dataset uri of the same pinned snapshot.
161+ // 7 . The fence locator is the normalized dataset uri of the same pinned snapshot.
155162 String locator = normalizeLocator (snapshot );
156- // 7 . Deterministic normalized properties JSON for ANN; scalar families persist null.
163+ // 8 . Deterministic normalized properties JSON for ANN; scalar families persist null.
157164 boolean ann = def .getLanceIndexType () == null ;
158165 String indexType = ann ? annIndexType (def ) : def .getLanceIndexType ();
159166 String propertiesJson = ann ? buildAnnPropertiesJson (def ) : null ;
160167 // D7 backstop: quota values from fe.conf bypass the ADMIN SET callback, so admission
161168 // re-asserts positivity before any id allocation or durable transfer.
162169 assertPositiveQuotas ();
163170 return catalogMgr .withLanceIndexAdmission (catalog , target , () -> {
164- // 8 . Exactly one id allocation, after every preflight above has passed.
171+ // 9 . Exactly one id allocation, after every preflight above has passed.
165172 long jobId = Env .getCurrentEnv ().getNextId ();
166173 String creator = ConnectContext .get ().getQualifiedUser ();
167- // 9 . REPLACE on an existing name persists the stored display name (section 4.1) so the
174+ // 10 . REPLACE on an existing name persists the stored display name (section 4.1) so the
168175 // worker locates the case-sensitive target; a fresh REPLACE keeps the user's spelling.
169176 String persistedDisplayName = (orReplace && storedName != null ) ? storedName : displayName ;
170177 LanceIndexJob job ;
@@ -181,7 +188,7 @@ static Outcome admitCreate(SnapshotLoader loader, LanceExternalCatalog catalog,
181188 Config .lance_index_job_max_unresolved_per_table ,
182189 Config .lance_index_job_max_unresolved_per_catalog ,
183190 Config .lance_index_job_max_unresolved_global );
184- // 10 . The job and its fence are durable once createJob returns.
191+ // 11 . The job and its fence are durable once createJob returns.
185192 return new Outcome (jobId );
186193 });
187194 }
@@ -246,9 +253,11 @@ static Outcome admitDrop(SnapshotLoader loader, LanceExternalCatalog catalog,
246253 * stored logical algorithm under family normalization — a same-name different-algorithm
247254 * request is a mismatch, never a no-op; (b) the physical entry of the same name must exist
248255 * and back the logical algorithm (snapshot self-consistency, failing closed); (c) the single
249- * normalized column must be equal; (d) whitelist properties are compared per property — a
256+ * normalized column, with the request side in the same loader path-segment representation as
257+ * the logical side, must be equal; (d) whitelist properties are compared per property — a
250258 * value the request sets and the snapshot exposes must be equal, an unexposed snapshot value
251- * is skipped, and a property the request omits is never compared.
259+ * is skipped, and a property the request omits is never compared except num_bits, whose
260+ * omitted request value defaults to the always-persisted 8.
252261 */
253262 private static boolean matchesExistingDefinition (LanceIndexAdmissionSnapshot snapshot ,
254263 String storedName , IndexDefinition def ) {
@@ -281,7 +290,12 @@ private static boolean matchesExistingDefinition(LanceIndexAdmissionSnapshot sna
281290 if (logical .getColumns ().size () != 1 ) {
282291 return false ;
283292 }
284- String requestColumn = LanceIndexNameNormalizer .normalize (def .getCols ().get (0 ));
293+ // The logical column is a loader path segment: field names containing characters outside
294+ // [A-Za-z0-9_] are backtick-escaped (embedded backticks doubled). The parser hands over the
295+ // raw spelling, so the request column is formatted with the same rule before both sides
296+ // pass name normalization — otherwise such columns are falsely rejected as a mismatch.
297+ String requestColumn = LanceIndexNameNormalizer .normalize (
298+ LanceIndexMetadataLoader .formatFieldPathSegment (def .getCols ().get (0 )));
285299 if (!LanceIndexNameNormalizer .normalize (logical .getColumns ().get (0 )).equals (requestColumn )) {
286300 return false ;
287301 }
@@ -290,9 +304,9 @@ private static boolean matchesExistingDefinition(LanceIndexAdmissionSnapshot sna
290304
291305 /**
292306 * Per-property whitelist comparison (metric ↔ metric_type, num_sub_vectors ↔
293- * compression.num_sub_vectors, num_bits ↔ compression.num_bits). num_partitions is never
294- * compared (section 2.2 ). BTREE/BITMAP carry no user build properties, so the comparison is
295- * vacuous for them.
307+ * compression.num_sub_vectors, num_bits ↔ compression.num_bits; an omitted request num_bits
308+ * compares as the always-persisted 8 ). num_partitions is never compared (section 2.2).
309+ * BTREE/BITMAP carry no user build properties, so the comparison is vacuous for them.
296310 */
297311 private static boolean whitelistPropertiesMatch (LanceLogicalIndex logical , IndexDefinition def ) {
298312 if (def .getLanceIndexType () != null ) {
@@ -327,8 +341,12 @@ private static boolean whitelistPropertiesMatch(LanceLogicalIndex logical, Index
327341 }
328342 JsonObject compression = exposed == null || !exposed .has ("compression" )
329343 ? null : exposed .getAsJsonObject ("compression" );
344+ // The request side of num_bits is never "unset": the validator accepts an omitted value
345+ // and the persisted job pins it to 8 (section 2.4), so the preflight compares an
346+ // effective 8 against any exposed compression.num_bits instead of skipping.
347+ String requestNumBits = request .get ("num_bits" ) == null ? "8" : request .get ("num_bits" );
330348 return numericPropertyMatches (request .get ("num_sub_vectors" ), compression , "num_sub_vectors" )
331- && numericPropertyMatches (request . get ( "num_bits" ) , compression , "num_bits" );
349+ && numericPropertyMatches (requestNumBits , compression , "num_bits" );
332350 }
333351
334352 /**
@@ -438,6 +456,28 @@ private static Map<String, String> normalizedAnnProperties(Map<String, String> p
438456 return normalized ;
439457 }
440458
459+ /**
460+ * Fails closed when more than one top-level field of the pinned snapshot matches the request
461+ * column under the table lookup relation {@code String.equalsIgnoreCase} — deliberately not a
462+ * ROOT-lowercase fold, which decides some Unicode pairs differently and would diverge from
463+ * the relation {@link org.apache.doris.datasource.ExternalTable#getColumn} actually applies.
464+ * That lookup returns the first hit, so an ambiguous request could journal the wrong stored
465+ * column (design section 4.1 fail-closed rule, applied to columns).
466+ */
467+ private static void rejectIfAmbiguousLookupColumn (LanceIndexAdmissionSnapshot snapshot ,
468+ String requestColumn ) throws AnalysisException {
469+ int matches = 0 ;
470+ for (LanceField field : snapshot .getTopLevelFields ()) {
471+ if (requestColumn .equalsIgnoreCase (field .getName ())) {
472+ ++matches ;
473+ }
474+ }
475+ if (matches > 1 ) {
476+ rejectInvalid ("index column '" + requestColumn
477+ + "' is ambiguous: multiple Lance fields differ only by case" );
478+ }
479+ }
480+
441481 private static String storedColumnName (LanceExternalTable table , String requestColumn )
442482 throws AnalysisException {
443483 Column column = table .getColumn (requestColumn );
0 commit comments