-
Notifications
You must be signed in to change notification settings - Fork 528
Fix OPTIMIZED_SIBLING_CHECK ancestor overlap #5520
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -108,6 +108,7 @@ | |||
| import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogRolesObj; | ||||
| import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogStateObj; | ||||
| import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogsObj; | ||||
| import org.apache.polaris.persistence.nosql.coretypes.catalog.EntityIdSet; | ||||
| import org.apache.polaris.persistence.nosql.coretypes.content.ContentObj; | ||||
| import org.apache.polaris.persistence.nosql.coretypes.mapping.EntityObjMappings; | ||||
| import org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalObj; | ||||
|
|
@@ -630,12 +631,22 @@ <T extends PolarisEntity & LocationBasedEntity> Optional<String> hasOverlappingS | |||
| return Optional.empty(); | ||||
| } | ||||
|
|
||||
| var catalogId = entity.getCatalogId(); | ||||
| var checkLocation = StorageLocation.of(baseLocation).withoutScheme(); | ||||
| var entityLocation = StorageLocation.of(baseLocation); | ||||
|
|
||||
| // The entity's own parent namespaces contain its location by construction; they are not | ||||
| // siblings. Resolve the parent chain up front via the (memoized) id index. | ||||
| var ancestorIds = new HashSet<Long>(); | ||||
| for (var id = entity.getParentId(); | ||||
| id != PolarisEntityConstants.getNullId() && id != catalogId && ancestorIds.add(id); ) { | ||||
| var ancestor = lookupEntity(catalogId, id, PolarisEntityType.NAMESPACE.getCode()); | ||||
| if (ancestor == null) { | ||||
| break; | ||||
| } | ||||
| id = ancestor.getParentId(); | ||||
| } | ||||
|
|
||||
| return hasOverlappingSiblings(entity.getCatalogId(), checkLocation); | ||||
| } | ||||
|
|
||||
| Optional<String> hasOverlappingSiblings(long catalogId, String checkLocation) { | ||||
| return memoizedIndexedAccess | ||||
| .catalogContent(catalogId) | ||||
| .refObj() | ||||
|
|
@@ -654,36 +665,61 @@ Optional<String> hasOverlappingSiblings(long catalogId, String checkLocation) { | |||
| var locationIdentifier = identifierFromLocationString(checkLocation); | ||||
| var locationIndexKey = locationIdentifier.toIndexKey(); | ||||
|
|
||||
| // Resolves an index entry to the base location of the first entity in it that | ||||
| // actually overlaps the entity being checked. | ||||
| Function<EntityIdSet, Optional<String>> firstOverlap = | ||||
| entityIdSet -> | ||||
| entityIdSet.entityIds().stream() | ||||
| .map(IndexKey::key) | ||||
| .map(byId::get) | ||||
| .filter(Objects::nonNull) | ||||
| .map(byName::get) | ||||
| .filter(Objects::nonNull) | ||||
| .map(objRef -> persistence.fetch(objRef, ContentObj.class)) | ||||
| .filter(Objects::nonNull) | ||||
| .map(contentObj -> mapToEntity(contentObj, catalogId)) | ||||
| .filter( | ||||
| candidate -> { | ||||
| // The entity itself being re-created is an already-exists | ||||
| // condition for the create, not an overlap. | ||||
| if (candidate.getParentId() == entity.getParentId() | ||||
| && candidate.getType() == entity.getType() | ||||
| && candidate.getName().equals(entity.getName())) { | ||||
| return false; | ||||
|
Comment on lines
+685
to
+688
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new test doesn't exercise this skip, iirc. Could we cover recreating a non-empty namespace as well? In #5521, parent.child and parent.t are created before parent is recreated. This filter skips parent itself, but its children still count as overlaps, so createNamespaceInternal throws 403 before reaching the create operation that would return 409. This affects all three implementations I believe. We should cover both empty and non-empty namespace recreation returning AlreadyExists, while retaining overlap rejection for genuinely new namespaces. |
||||
| } | ||||
| var candidateBaseLocation = | ||||
| candidate.getPropertiesAsMap().get(ENTITY_BASE_LOCATION); | ||||
| if (candidateBaseLocation == null | ||||
| || candidateBaseLocation.isBlank()) { | ||||
| return false; | ||||
| } | ||||
| var candidateLocation = StorageLocation.of(candidateBaseLocation); | ||||
| var containsEntity = entityLocation.isChildOf(candidateLocation); | ||||
| var containedByEntity = candidateLocation.isChildOf(entityLocation); | ||||
| // An ancestor may contain the entity, but the entity may not sit | ||||
| // at exactly its location. | ||||
| if (containsEntity | ||||
| && !containedByEntity | ||||
| && ancestorIds.contains(candidate.getId())) { | ||||
| return false; | ||||
| } | ||||
| return containsEntity || containedByEntity; | ||||
| }) | ||||
| .map( | ||||
| candidate -> candidate.getPropertiesAsMap().get(ENTITY_BASE_LOCATION)) | ||||
| .findFirst(); | ||||
|
|
||||
| // Check for children and exact matches first using forward iteration (preserves | ||||
| // existing test expectations on which conflicting location is reported). | ||||
| // Also iterate fully in case early entries are filtered out. | ||||
| var iter = locationsIndex.iterator(locationIndexKey, null, false); | ||||
| while (iter.hasNext()) { | ||||
| var elem = iter.next(); | ||||
| var elemKey = elem.key(); | ||||
| var elemIdentifier = indexKeyToIdentifier(elemKey); | ||||
| var elemIdentifier = indexKeyToIdentifier(elem.key()); | ||||
| if (!elemIdentifier.startsWith(locationIdentifier)) { | ||||
| break; // No more matches due to ordering | ||||
| } | ||||
|
|
||||
| var conflicting = | ||||
| elem.value().entityIds().stream() | ||||
| .map(IndexKey::key) | ||||
| .map(byId::get) | ||||
| .filter(Objects::nonNull) | ||||
| .map(byName::get) | ||||
| .filter(Objects::nonNull) | ||||
| .map(objRef -> persistence.fetch(objRef, ContentObj.class)) | ||||
| .filter(Objects::nonNull) | ||||
| .map( | ||||
| contentObj -> { | ||||
| var conflictingBaseLocation = | ||||
| contentObj.properties().get(ENTITY_BASE_LOCATION); | ||||
| return conflictingBaseLocation != null | ||||
| ? conflictingBaseLocation | ||||
| : String.join("/", elemIdentifier.elements()); | ||||
| }) | ||||
| .findFirst(); | ||||
| var conflicting = firstOverlap.apply(elem.value()); | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The old code for this case does not break the new test : |
||||
| if (conflicting.isPresent()) { | ||||
| return conflicting; | ||||
| } | ||||
|
|
@@ -692,29 +728,11 @@ Optional<String> hasOverlappingSiblings(long catalogId, String checkLocation) { | |||
| // Check for parent (prefix) overlaps. These have shorter keys and are missed by | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Per method's javadoc, it looks like only siblings need to be checked... why do we recurse into all parents? 🤔
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This part apparently comes from #4873. @vigneshio : Could you recap why this logic was needed?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It looks like the behaviour of
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
@dimas-b the prefix loop isn't walking #5520's #1686 introduced the API and the "siblings" name, but the query was already catalog-scoped: The leftover In-memory does the same full scan with That's the mismatch behind #5521, and it predates #4873. The flag-off path is still the same-parent list in What #4873 fixed is narrower. NoSQL keys are path components, and the iterator started at the full target key, so shorter containing keys sorted before the start and were never visited. The regression is a foreign occupant on a parent path, not "my own parent namespace":
Side effect: before #4873, NoSQL also never saw its own parent, so nested default-location creates passed there by accident. Afterwards it found the parent and reported it - same as JDBC and in-memory already did. That's why #5521 reproduces on all three (catalog-root creates escape because catalogs aren't in the location index). #5520 draws the right line:
One thing I noticed is I don't see a direct test for that last skip. A locked pair would cover it: foreign prefix still overlaps; own parent on a default child location does not; re-create is 409 not 403. Javadoc for catalog-wide containment would help; renaming the method isn't needed to land this.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the analysis, @vigneshio !
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So going to the caller code - Line 1527 in 3bdf7da
However, if This is a logical inconsistency, IMHO, because the flag indicates an optimization, so the behaviour should remain the same with or without the flag (plus or minus performance effects). I'll open a
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||
| // forward iteration starting at the full target key. | ||||
| for (int i = 1; i <= locationIdentifier.length(); i++) { | ||||
| var prefixElements = locationIdentifier.elements().subList(0, i); | ||||
| var prefix = ContentIdentifier.identifier(prefixElements); | ||||
| var prefixKey = prefix.toIndexKey(); | ||||
| var entry = locationsIndex.get(prefixKey); | ||||
| var prefix = | ||||
| ContentIdentifier.identifier(locationIdentifier.elements().subList(0, i)); | ||||
| var entry = locationsIndex.get(prefix.toIndexKey()); | ||||
| if (entry != null) { | ||||
| var conflicting = | ||||
| entry.entityIds().stream() | ||||
| .map(IndexKey::key) | ||||
| .map(byId::get) | ||||
| .filter(Objects::nonNull) | ||||
| .map(byName::get) | ||||
| .filter(Objects::nonNull) | ||||
| .map(objRef -> persistence.fetch(objRef, ContentObj.class)) | ||||
| .filter(Objects::nonNull) | ||||
| .map( | ||||
| contentObj -> { | ||||
| var conflictingBaseLocation = | ||||
| contentObj.properties().get(ENTITY_BASE_LOCATION); | ||||
| return conflictingBaseLocation != null | ||||
| ? conflictingBaseLocation | ||||
| : String.join("/", prefix.elements()); | ||||
| }) | ||||
| .findFirst(); | ||||
| var conflicting = firstOverlap.apply(entry); | ||||
| if (conflicting.isPresent()) { | ||||
| return conflicting; | ||||
| } | ||||
|
|
||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we have a test for this case? (I might have missed it 😅 )