diff --git a/LICENSE b/LICENSE index 8f50538f8d..f4da09601b 100644 --- a/LICENSE +++ b/LICENSE @@ -230,6 +230,16 @@ License: https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- +This product includes code from Netty. + +* persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/ResolvConf.java + +Copyright: Copyright © 2025 The Netty project +Home page: https://netty.io/ +License: https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + This product includes code from OpenAPITool openapi-generator * server-templates/formParams.mustache @@ -328,6 +338,9 @@ This product includes code from Project Nessie. * helm/polaris/templates/servicemonitor.yaml * helm/polaris/templates/storage.yaml +Code underneath the components/persistence directory, especially pluggable object types, index related, cache, +atomic commit logic and fundamental persistence implementations. + Copyright: Copyright 2015-2025 Dremio Corporation Home page: https://projectnessie.org/ License: https://www.apache.org/licenses/LICENSE-2.0 diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts index c959cfb766..0d010ecd9f 100644 --- a/bom/build.gradle.kts +++ b/bom/build.gradle.kts @@ -47,22 +47,36 @@ dependencies { api(project(":polaris-nodes-api")) api(project(":polaris-nodes-impl")) api(project(":polaris-nodes-spi")) + api(project(":polaris-nodes-store-nosql")) + + api(project(":polaris-persistence-nosql-authz-api")) + api(project(":polaris-persistence-nosql-authz-impl")) + api(project(":polaris-persistence-nosql-authz-spi")) + api(project(":polaris-persistence-nosql-authz-store-nosql")) api(project(":polaris-persistence-nosql-realms-api")) api(project(":polaris-persistence-nosql-realms-impl")) api(project(":polaris-persistence-nosql-realms-spi")) + api(project(":polaris-persistence-nosql-realms-store-nosql")) api(project(":polaris-persistence-nosql-api")) api(project(":polaris-persistence-nosql-impl")) api(project(":polaris-persistence-nosql-benchmark")) + api(project(":polaris-persistence-nosql-metastore")) api(project(":polaris-persistence-nosql-correctness")) + api(project(":polaris-persistence-nosql-cdi-common")) + api(project(":polaris-persistence-nosql-cdi-quarkus")) + api(project(":polaris-persistence-nosql-cdi-quarkus-distcache")) + api(project(":polaris-persistence-nosql-cdi-weld")) api(project(":polaris-persistence-nosql-standalone")) api(project(":polaris-persistence-nosql-testextension")) + api(project(":polaris-persistence-nosql-types")) api(project(":polaris-persistence-nosql-inmemory")) api(project(":polaris-persistence-nosql-mongodb")) api(project(":polaris-persistence-nosql-maintenance-api")) + api(project(":polaris-persistence-nosql-maintenance-impl")) api(project(":polaris-persistence-nosql-maintenance-cel")) api(project(":polaris-persistence-nosql-maintenance-spi")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3423eaf4c7..2bb9e7c18f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,6 +51,7 @@ caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version = "3.2.3 cel-bom = { module = "org.projectnessie.cel:cel-bom", version = "0.5.3" } commons-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.19.0" } commons-text = { module = "org.apache.commons:commons-text", version = "1.14.0" } +docker-java-api = { module = "com.github.docker-java:docker-java-api", version = "3.6.0" } errorprone = { module = "com.google.errorprone:error_prone_core", version = "2.44.0" } google-cloud-storage-bom = { module = "com.google.cloud:google-cloud-storage-bom", version = "2.60.0" } guava = { module = "com.google.guava:guava", version = "33.5.0-jre" } @@ -75,12 +76,12 @@ jakarta-servlet-api = { module = "jakarta.servlet:jakarta.servlet-api", version jakarta-validation-api = { module = "jakarta.validation:jakarta.validation-api", version = "3.1.1" } jakarta-ws-rs-api = { module = "jakarta.ws.rs:jakarta.ws.rs-api", version = "4.0.0" } javax-servlet-api = { module = "javax.servlet:javax.servlet-api", version = "4.0.1" } -junit-bom = { module = "org.junit:junit-bom", version = "5.14.1" } -junit-pioneer = { module = "org.junit-pioneer:junit-pioneer", version = "2.3.0" } -keycloak-admin-client = { module = "org.keycloak:keycloak-admin-client", version = "26.0.7" } jcstress-core = { module = "org.openjdk.jcstress:jcstress-core", version = "0.16" } jmh-core = { module = "org.openjdk.jmh:jmh-core", version.ref = "jmh" } jmh-generator-annprocess = { module = "org.openjdk.jmh:jmh-generator-annprocess", version.ref = "jmh" } +junit-bom = { module = "org.junit:junit-bom", version = "5.14.1" } +junit-pioneer = { module = "org.junit-pioneer:junit-pioneer", version = "2.3.0" } +keycloak-admin-client = { module = "org.keycloak:keycloak-admin-client", version = "26.0.7" } logback-classic = { module = "ch.qos.logback:logback-classic", version = "1.5.21" } micrometer-bom = { module = "io.micrometer:micrometer-bom", version = "1.16.0" } microprofile-fault-tolerance-api = { module = "org.eclipse.microprofile.fault-tolerance:microprofile-fault-tolerance-api", version = "4.1.2" } diff --git a/gradle/projects.main.properties b/gradle/projects.main.properties index 1b50c9ce4c..84e167c756 100644 --- a/gradle/projects.main.properties +++ b/gradle/projects.main.properties @@ -62,20 +62,34 @@ polaris-idgen-spi=persistence/nosql/idgen/spi polaris-nodes-api=persistence/nosql/nodes/api polaris-nodes-impl=persistence/nosql/nodes/impl polaris-nodes-spi=persistence/nosql/nodes/spi +polaris-nodes-store-nosql=persistence/nosql/nodes/store-nosql +# authz +polaris-persistence-nosql-authz-api=persistence/nosql/authz/api +polaris-persistence-nosql-authz-impl=persistence/nosql/authz/impl +polaris-persistence-nosql-authz-spi=persistence/nosql/authz/spi +polaris-persistence-nosql-authz-store-nosql=persistence/nosql/authz/store-nosql # realms polaris-persistence-nosql-realms-api=persistence/nosql/realms/api polaris-persistence-nosql-realms-impl=persistence/nosql/realms/impl polaris-persistence-nosql-realms-spi=persistence/nosql/realms/spi +polaris-persistence-nosql-realms-store-nosql=persistence/nosql/realms/store-nosql # persistence / database agnostic polaris-persistence-nosql-api=persistence/nosql/persistence/api polaris-persistence-nosql-impl=persistence/nosql/persistence/impl polaris-persistence-nosql-benchmark=persistence/nosql/persistence/benchmark +polaris-persistence-nosql-metastore=persistence/nosql/persistence/metastore polaris-persistence-nosql-correctness=persistence/nosql/persistence/correctness +polaris-persistence-nosql-cdi-common=persistence/nosql/persistence/cdi/common +polaris-persistence-nosql-cdi-quarkus=persistence/nosql/persistence/cdi/quarkus +polaris-persistence-nosql-cdi-quarkus-distcache=persistence/nosql/persistence/cdi/quarkus-distcache +polaris-persistence-nosql-cdi-weld=persistence/nosql/persistence/cdi/weld polaris-persistence-nosql-standalone=persistence/nosql/persistence/standalone polaris-persistence-nosql-testextension=persistence/nosql/persistence/testextension +polaris-persistence-nosql-types=persistence/nosql/persistence/types polaris-persistence-nosql-varint=persistence/nosql/persistence/varint # persistence / maintenance polaris-persistence-nosql-maintenance-api=persistence/nosql/persistence/maintenance/api +polaris-persistence-nosql-maintenance-impl=persistence/nosql/persistence/maintenance/impl polaris-persistence-nosql-maintenance-cel=persistence/nosql/persistence/maintenance/retain-cel polaris-persistence-nosql-maintenance-spi=persistence/nosql/persistence/maintenance/spi # persistence / database specific implementations diff --git a/persistence/nosql/authz/README.md b/persistence/nosql/authz/README.md new file mode 100644 index 0000000000..a6c6fe830d --- /dev/null +++ b/persistence/nosql/authz/README.md @@ -0,0 +1,64 @@ + + +# AuthZ framework with pluggable privileges + +Provides a framework and implementations pluggable privileges and privilege checks. + +## Privileges + +A privilege is globally identified by its name. Privileges can be inheritable (from its parents) or not. Multiple +privileges can be grouped together to a _composite_ privilege (think: `ALL_DML` having `SELECT`, `INSERT`, `UPDATE` and +`DELETE`) - a composite privilege matches, if all its individual privileges match. Multiple privileges can also be +grouped to an _alternative_ privilege, which matches if any of its individual privileges matches. + +Available privileges are provided by one or more `PrivilegeProvider`s, which are discovered at runtime. +Note: currently there is only one `ProvilegeProvider` that plugs in the Polaris privileges. + +## ACLs, ACL entries and ACL chains + +Each securable object can have its own ACL. ACLs consist of ACL entries, which define the _granted_ and _restricted_ +privileges by role name. The the number of roles is technically unbounded and the number of ACL entries can become +quite large. + +This framework implements [separation of duties](https://en.wikipedia.org/wiki/Separation_of_duties) ("SoD"), which is a +quite demanded functionality not just by large(r) user organizations. TL;DR _SoD_ allows "security administrators" to +grant and revoke privileges to other users, but not leverage those privileges themselves. + +The _effective_ set of privileges for a specific operation performed by a specific caller needs to be computed against +the target objects and their parents. _ACL chains_ are the vehicle to model this hierarchy and let the implementation +compute the set of _effective_ privileges based on the individual ACLs and roles. + +Note: Privilege checks and _SoD_ are currently not performed via this framework. + +## Jackson support & Storage friendly representation + +The persistable types `Acl`, `AclEntry`, and `PrivilegeSet` can all be serialized using Jackson. + +As the number of ACL entries can become quite large, space efficient serialization is quite important. The +implementation uses bit-set encoding when serializing `PrivilegeSet`s for persistence. + +## Code structure + +The code is structured into multiple modules. Consuming code should almost always pull in only the API module. + +* `polaris-authz-api` provides the necessary Java interfaces and immutable types. +* `polaris-authz-impl` provides the storage agnostic implementation. +* `polaris-authz-spi` provides the necessary interfaces to provide custom privileges and storage implementation. +* `polaris-authz-store-nosql` provides the storage implementation based on `polaris-persistence-nosql-api`. diff --git a/persistence/nosql/authz/api/build.gradle.kts b/persistence/nosql/authz/api/build.gradle.kts new file mode 100644 index 0000000000..f6908d50c7 --- /dev/null +++ b/persistence/nosql/authz/api/build.gradle.kts @@ -0,0 +1,40 @@ +/* + * 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris AuthZ API" + +dependencies { + implementation(libs.guava) + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-databind") + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Acl.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Acl.java new file mode 100644 index 0000000000..a4111f1f25 --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Acl.java @@ -0,0 +1,57 @@ +/* + * 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.polaris.persistence.nosql.authz.api; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import jakarta.annotation.Nonnull; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public interface Acl { + + void entriesForRoleIds( + @Nonnull Set roleIds, @Nonnull Consumer aclEntryConsumer); + + void forEach(@Nonnull BiConsumer consumer); + + interface AclBuilder { + @CanIgnoreReturnValue + AclBuilder from(@Nonnull Acl instance); + + @CanIgnoreReturnValue + AclBuilder addEntry(@Nonnull String roleId, @Nonnull AclEntry entry); + + @CanIgnoreReturnValue + AclBuilder removeEntry(@Nonnull String roleId); + + /** + * Add, remove or update an {@linkplain AclEntry ACL entry} for a role. + * + *

The {@linkplain Consumer consumer} is called with an empty builder, if no ACL entry for + * the role exists, otherwise with a builder constructed from the existing entry. If the given + * {@linkplain Consumer consumer} removes all privileges from the ACL entry, the ACL entry will + * be removed. + */ + @CanIgnoreReturnValue + AclBuilder modify(@Nonnull String roleId, @Nonnull Consumer entry); + + Acl build(); + } +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclChain.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclChain.java new file mode 100644 index 0000000000..74e73d266c --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclChain.java @@ -0,0 +1,37 @@ +/* + * 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.polaris.persistence.nosql.authz.api; + +import java.util.Optional; +import org.apache.polaris.immutables.PolarisImmutable; +import org.immutables.value.Value; + +/** Container for an {@linkplain Acl ACL} of an individual entity and a pointer to its parent. */ +@PolarisImmutable +public interface AclChain { + @Value.Parameter(order = 1) + Acl acl(); + + @Value.Parameter(order = 2) + Optional parent(); + + static AclChain aclChain(Acl acl, Optional parent) { + return ImmutableAclChain.of(acl, parent); + } +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclEntry.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclEntry.java new file mode 100644 index 0000000000..d8a290990b --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclEntry.java @@ -0,0 +1,112 @@ +/* + * 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.polaris.persistence.nosql.authz.api; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import jakarta.annotation.Nonnull; +import java.util.Collection; +import org.apache.polaris.immutables.PolarisImmutable; +import org.immutables.value.Value; + +/** + * An {@link Acl ACL} entry holds the {@linkplain PrivilegeSet sets} of granted and + * restricted ("separation of duties") {@linkplain Privilege privileges}. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutableAclEntry.class) +@JsonDeserialize(as = ImmutableAclEntry.class) +public interface AclEntry { + @Value.Parameter(order = 1) + @Value.Default + // The 'CUSTOM/valueFilter' combination is there to only include non-empty privilege sets + @JsonInclude(value = JsonInclude.Include.CUSTOM, valueFilter = PrivilegeSetJsonFilter.class) + default PrivilegeSet granted() { + return PrivilegeSet.emptyPrivilegeSet(); + } + + @Value.Parameter(order = 2) + @Value.Default + // The 'CUSTOM/valueFilter' combination is there to only include non-empty privilege sets + @JsonInclude(value = JsonInclude.Include.CUSTOM, valueFilter = PrivilegeSetJsonFilter.class) + default PrivilegeSet restricted() { + return PrivilegeSet.emptyPrivilegeSet(); + } + + @Value.NonAttribute + @JsonIgnore + default boolean isEmpty() { + return granted().isEmpty() && restricted().isEmpty(); + } + + interface AclEntryBuilder { + @CanIgnoreReturnValue + AclEntryBuilder grant(@Nonnull Collection privileges); + + @CanIgnoreReturnValue + AclEntryBuilder grant(@Nonnull Privilege... privileges); + + @CanIgnoreReturnValue + AclEntryBuilder grant(@Nonnull Privilege privilege); + + @CanIgnoreReturnValue + AclEntryBuilder grant(@Nonnull PrivilegeSet privileges); + + @CanIgnoreReturnValue + AclEntryBuilder revoke(@Nonnull Collection privileges); + + @CanIgnoreReturnValue + AclEntryBuilder revoke(@Nonnull Privilege... privileges); + + @CanIgnoreReturnValue + AclEntryBuilder revoke(@Nonnull Privilege privilege); + + @CanIgnoreReturnValue + AclEntryBuilder revoke(@Nonnull PrivilegeSet privileges); + + @CanIgnoreReturnValue + AclEntryBuilder restrict(@Nonnull Collection privileges); + + @CanIgnoreReturnValue + AclEntryBuilder restrict(@Nonnull Privilege... privileges); + + @CanIgnoreReturnValue + AclEntryBuilder restrict(@Nonnull Privilege privilege); + + @CanIgnoreReturnValue + AclEntryBuilder restrict(@Nonnull PrivilegeSet privileges); + + @CanIgnoreReturnValue + AclEntryBuilder unrestrict(@Nonnull Collection privileges); + + @CanIgnoreReturnValue + AclEntryBuilder unrestrict(@Nonnull Privilege... privileges); + + @CanIgnoreReturnValue + AclEntryBuilder unrestrict(@Nonnull Privilege privilege); + + @CanIgnoreReturnValue + AclEntryBuilder unrestrict(@Nonnull PrivilegeSet privileges); + + AclEntry build(); + } +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Constants.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Constants.java new file mode 100644 index 0000000000..ad54d6d117 --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Constants.java @@ -0,0 +1,143 @@ +/* + * 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.polaris.persistence.nosql.authz.api; + +import static java.util.Collections.emptyIterator; + +import jakarta.annotation.Nonnull; +import java.util.Collection; +import java.util.Iterator; + +final class Constants { + + public static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + + private Constants() {} + + static final PrivilegeSet EMPTY_PRIVILEGE_SET = + new PrivilegeSet() { + @Override + public boolean contains(Privilege privilege) { + return false; + } + + @Override + public Iterator iterator(Privileges privileges) { + return emptyIterator(); + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public byte[] toByteArray() { + return EMPTY_BYTE_ARRAY; + } + + @Override + public boolean contains(Object o) { + return false; + } + + @Override + public boolean containsAll(@Nonnull Collection c) { + return false; + } + + @Override + public boolean containsAny(Iterable privilege) { + return false; + } + + @Override + public int size() { + return 0; + } + + @Override + @Nonnull + public Iterator iterator() { + return emptyIterator(); + } + + @Override + @Nonnull + public Object[] toArray() { + return new Object[0]; + } + + @Override + @Nonnull + public T[] toArray(T[] a) { + @SuppressWarnings("unchecked") + var r = (T[]) new Object[a.length]; + return r; + } + + @Override + public boolean add(Privilege privilege) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean remove(Object o) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean addAll(@Nonnull Collection c) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean retainAll(@Nonnull Collection c) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean removeAll(@Nonnull Collection c) { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof PrivilegeSet privilegeSet) { + return privilegeSet.isEmpty(); + } + return false; + } + + @Override + public int hashCode() { + return -1; + } + + @Override + public String toString() { + return "PrivilegeSet{}"; + } + }; +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PredefinedRoles.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PredefinedRoles.java new file mode 100644 index 0000000000..758f80ee48 --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PredefinedRoles.java @@ -0,0 +1,29 @@ +/* + * 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.polaris.persistence.nosql.authz.api; + +public final class PredefinedRoles { + private PredefinedRoles() {} + + /** Unauthenticated requests. */ + public static final String ANONYMOUS_ROLE = ""; + + /** All authenticated users. */ + public static final String PUBLIC_ROLE = ""; +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privilege.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privilege.java new file mode 100644 index 0000000000..b26c244dd4 --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privilege.java @@ -0,0 +1,158 @@ +/* + * 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.polaris.persistence.nosql.authz.api; + +import static com.google.common.base.Preconditions.checkArgument; + +import java.util.Set; +import org.apache.polaris.immutables.PolarisImmutable; +import org.immutables.value.Value; + +/** + * Represents an individual or composite privilege. + * + *

Composite privileges consist of multiple individual privileges. + * + *

External representations, for example, when serialized as JSON, prefer composite privileges + * over individual ones. This means that if a {@linkplain PrivilegeSet privilege set} contains all + * privileges included in a composite privilege, only the composite privilege is serialized. If + * multiple composite privileges match, all matching ones are serialized. + */ +public interface Privilege { + String name(); + + Set resolved(); + + default boolean mustMatchAll() { + return true; + } + + interface IndividualPrivilege extends Privilege { + @Override + @Value.Auxiliary + default Set resolved() { + return Set.of(this); + } + } + + /** + * Inheritable privileges apply to the checked entity and its child entities, if applied to the + * entity's ACL or any of its parents' ACLs. + */ + @PolarisImmutable + interface InheritablePrivilege extends IndividualPrivilege { + @Override + @Value.Parameter + String name(); + + static IndividualPrivilege inheritablePrivilege(String name) { + return ImmutableInheritablePrivilege.of(name); + } + } + + /** + * Non-inheritable privileges apply only to the checked entity if those are present in the + * entity's ACL. Non-inheritable privileges that are present on an entity's parent are ignored + * during access checks. + */ + @PolarisImmutable + interface NonInheritablePrivilege extends IndividualPrivilege { + @Override + @Value.Parameter + String name(); + + static NonInheritablePrivilege nonInheritablePrivilege(String name) { + return ImmutableNonInheritablePrivilege.of(name); + } + } + + /** + * A composite privilege represents a group of {@linkplain IndividualPrivilege individual + * privileges}. + * + *

Access checks for a composite privilege only succeed if all individual + * privileges match. + * + * @see AlternativePrivilege + */ + @PolarisImmutable + interface CompositePrivilege extends Privilege { + @Value.Parameter(order = 1) + @Override + String name(); + + @Override + @Value.Parameter(order = 2) + Set resolved(); + + @Value.Check + default void check() { + checkArgument(!resolved().isEmpty(), "Must have at least one individual privilege"); + } + + static CompositePrivilege compositePrivilege( + String name, Iterable privileges) { + return ImmutableCompositePrivilege.of(name, privileges); + } + + static CompositePrivilege compositePrivilege(String name, IndividualPrivilege... privileges) { + return ImmutableCompositePrivilege.of(name, Set.of(privileges)); + } + } + + /** + * An "alternative privilege" represents a group of {@linkplain IndividualPrivilege individual + * privileges}. + * + *

Access checks for a alternative privilege succeed if any individual + * privileges of the alternative privilege matches. + * + * @see CompositePrivilege + */ + @PolarisImmutable + interface AlternativePrivilege extends Privilege { + @Value.Parameter(order = 1) + @Override + String name(); + + @Override + @Value.Parameter(order = 2) + Set resolved(); + + @Override + default boolean mustMatchAll() { + return false; + } + + @Value.Check + default void check() { + checkArgument(!resolved().isEmpty(), "Must have at least one individual privilege"); + } + + static AlternativePrivilege alternativePrivilege( + String name, Iterable privileges) { + return ImmutableAlternativePrivilege.of(name, privileges); + } + + static AlternativePrivilege alternativePrivilege( + String name, IndividualPrivilege... privileges) { + return ImmutableAlternativePrivilege.of(name, Set.of(privileges)); + } + } +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeCheck.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeCheck.java new file mode 100644 index 0000000000..81719b2c2f --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeCheck.java @@ -0,0 +1,52 @@ +/* + * 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.polaris.persistence.nosql.authz.api; + +import jakarta.annotation.Nonnull; + +public interface PrivilegeCheck { + /** + * Retrieve the effective privileges, which is the set of all granted privileges minus the set of + * all restricted privileges, for the given ACL and all its {@linkplain AclChain#parent() parent + * ACLs}. + * + *

The set of granted privileges contains all privileges that are {@linkplain + * AclEntry#granted() granted} to any of the role IDs for this {@linkplain PrivilegeCheck + * privilege check instance}. A privilege is granted if it is granted to any role in the given ACL + * or any of its parents. See note on non-inheritable privileges below. + * + *

The set of restricted privileges contains all privileges that are {@linkplain + * AclEntry#restricted() restricted} for any of the role IDs for this {@linkplain PrivilegeCheck + * privilege check instance}. A privilege is restricted if it is restricted to any role in the + * given ACL or any of its parents. See note on non-inheritable privileges below. + * + *

{@linkplain Privilege.NonInheritablePrivilege Non-inheritable} privileges are only effective + * on the "top" (first) ACL of the given {@linkplain AclChain ACL chain}, but are ignored on any + * of the parents. For example, a non-inheritable privilege {@code NON_INHERIT} that is + * granted on the entity's parent, will not be returned as an effective + * privilege. Similarly, non-inheritable privileges that are restricted on a parent, are + * not "subtracted" from the set of effective privileges. + * + *

A privilege is effective if it is granted and not restricted. + * + * @param aclChain Represents the chain of ACLs to check. The ACL for the entity must be the first + * one in the chain. + */ + PrivilegeSet effectivePrivilegeSet(@Nonnull AclChain aclChain); +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSet.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSet.java new file mode 100644 index 0000000000..38f42762f3 --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSet.java @@ -0,0 +1,119 @@ +/* + * 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.polaris.persistence.nosql.authz.api; + +import static org.apache.polaris.persistence.nosql.authz.api.Constants.EMPTY_PRIVILEGE_SET; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import jakarta.annotation.Nonnull; +import java.util.Collection; +import java.util.Iterator; +import java.util.Set; + +/** + * Represents a set of individual privileges. + * + *

External representations, for example, when serialized as JSON, prefer composite privileges + * over individual ones. This means that if a {@linkplain PrivilegeSet privilege set} contains all + * privileges included in a composite privilege, only the name of the composite privilege is + * serialized. If multiple composite privileges match, all matching ones are serialized. + * + *

This {@linkplain Set set} of {@linkplain Privilege privileges} natively represents only + * individual privileges. To "collapse" those into composite privileges, use {@link + * #collapseComposites(Privileges)}. + * + *

Composite privileges can however be used as arguments to all {@code contains()} functions and + * to the {@code add()}/{@code remove()} builder methods. + * + *

Do not use a {@link PrivilegeSet} when the special meaning of composite privileges + * needs to be retained, especially during access checks. + */ +public interface PrivilegeSet extends Set { + + static PrivilegeSet emptyPrivilegeSet() { + return EMPTY_PRIVILEGE_SET; + } + + boolean contains(Privilege privilege); + + Iterator iterator(Privileges privileges); + + @Override + boolean isEmpty(); + + byte[] toByteArray(); + + default Set collapseComposites(Privileges privileges) { + return privileges.collapseComposites(this); + } + + /** + * Checks whether the given {@link Privilege} is fully contained in this privilege set. + * + *

For {@linkplain Privilege.CompositePrivilege composite privileges}, returns {@code true} if + * and only if all the individual privileges of a composite privilege are contained in this set. + */ + @Override + boolean contains(Object o); + + /** + * Checks whether all of given {@link Privilege privileges} is contained in this privilege set. + * + *

For {@linkplain Privilege.CompositePrivilege composite privileges}, returns {@code true} if + * and only if all the individual privileges of a composite privilege are contained in this set. + */ + @Override + boolean containsAll(@Nonnull Collection c); + + /** + * Checks whether any of given {@link Privilege privileges} is contained in this privilege set. + * + *

For {@linkplain Privilege.CompositePrivilege composite privileges}, returns {@code true} if + * and only if all the individual privileges of a composite privilege are contained in this set. + */ + boolean containsAny(Iterable privilege); + + interface PrivilegeSetBuilder { + @CanIgnoreReturnValue + PrivilegeSetBuilder addPrivileges(@Nonnull Iterable privileges); + + @CanIgnoreReturnValue + PrivilegeSetBuilder addPrivileges(@Nonnull PrivilegeSet privilegeSet); + + @CanIgnoreReturnValue + PrivilegeSetBuilder addPrivileges(@Nonnull Privilege... privileges); + + @CanIgnoreReturnValue + PrivilegeSetBuilder addPrivilege(@Nonnull Privilege privilege); + + @CanIgnoreReturnValue + PrivilegeSetBuilder removePrivileges(@Nonnull Iterable privileges); + + @CanIgnoreReturnValue + PrivilegeSetBuilder removePrivileges(@Nonnull PrivilegeSet privilegeSet); + + @CanIgnoreReturnValue + PrivilegeSetBuilder removePrivileges(@Nonnull Privilege... privileges); + + @CanIgnoreReturnValue + PrivilegeSetBuilder removePrivilege(@Nonnull Privilege privilege); + + PrivilegeSet build(); + } +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSetJsonFilter.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSetJsonFilter.java new file mode 100644 index 0000000000..d1b54ee83e --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSetJsonFilter.java @@ -0,0 +1,32 @@ +/* + * 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.polaris.persistence.nosql.authz.api; + +final class PrivilegeSetJsonFilter { + @Override + public boolean equals(Object obj) { + return obj instanceof PrivilegeSet privilegeSet && privilegeSet.isEmpty(); + } + + @Override + public int hashCode() { + // never used + return 1; + } +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privileges.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privileges.java new file mode 100644 index 0000000000..a90c1d84ea --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privileges.java @@ -0,0 +1,76 @@ +/* + * 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.polaris.persistence.nosql.authz.api; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Collection; +import java.util.Set; + +/** + * Container holding all defined {@linkplain Privilege privileges}. + * + *

Implementation is provided as an {@link ApplicationScoped @ApplicationScoped} bean. + */ +public interface Privileges { + /** + * Return the {@linkplain Privilege privilege} for the given ID. + * + * @throws IllegalArgumentException if no privilege for the given ID exists. + */ + Privilege byId(int id); + + /** + * Return the {@linkplain Privilege privilege} for the given name (case-sensitive). + * + * @throws IllegalArgumentException if no privilege for the given name exists. + */ + Privilege byName(@Nonnull String name); + + int idForName(@Nonnull String name); + + int idForPrivilege(@Nonnull Privilege privilege); + + PrivilegeSet nonInheritablePrivileges(); + + /** + * Returns the set of {@linkplain Privilege privilege} from the given {@linkplain PrivilegeSet + * privilege set}, replacing all {@linkplain Privilege.IndividualPrivilege individual privileges} + * that fully match the {@linkplain Privilege.CompositePrivilege composite privileges}. If + * multiple composite privileges match, all of those will be returned. + */ + Set collapseComposites(@Nonnull PrivilegeSet value); + + /** Informative function, returns all known {@linkplain Privilege privileges}. */ + Collection all(); + + /** Informative function, the IDs provided all known {@linkplain Privilege privileges}. */ + Set allIds(); + + /** Informative function, returns the names of all known {@linkplain Privilege privileges}. */ + Set allNames(); + + PrivilegeSet.PrivilegeSetBuilder newPrivilegesSetBuilder(); + + Acl.AclBuilder newAclBuilder(); + + AclEntry.AclEntryBuilder newAclEntryBuilder(); + + PrivilegeCheck startPrivilegeCheck(boolean anonymous, Set roleIds); +} diff --git a/persistence/nosql/authz/api/src/main/resources/META-INF/beans.xml b/persistence/nosql/authz/api/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/authz/api/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/authz/impl/build.gradle.kts b/persistence/nosql/authz/impl/build.gradle.kts new file mode 100644 index 0000000000..fdc91d972b --- /dev/null +++ b/persistence/nosql/authz/impl/build.gradle.kts @@ -0,0 +1,60 @@ +/* + * 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris AuthZ implementation" + +dependencies { + implementation(project(":polaris-persistence-nosql-authz-api")) + implementation(project(":polaris-persistence-nosql-authz-spi")) + api(project(":polaris-version")) + + implementation(libs.agrona) + implementation(libs.guava) + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-databind") + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + + testFixturesApi(libs.jakarta.annotation.api) + testFixturesApi(libs.jakarta.validation.api) + testFixturesApi(libs.jakarta.inject.api) + testFixturesApi(libs.jakarta.enterprise.cdi.api) + + testFixturesImplementation(project(":polaris-persistence-nosql-authz-api")) + testFixturesImplementation(project(":polaris-persistence-nosql-authz-spi")) + + testImplementation(libs.weld.se.core) + testImplementation(libs.weld.junit5) + testRuntimeOnly(libs.smallrye.jandex) + + testFixturesCompileOnly(platform(libs.jackson.bom)) + testFixturesCompileOnly("com.fasterxml.jackson.core:jackson-databind") +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclDeserializer.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclDeserializer.java new file mode 100644 index 0000000000..6bef3cb976 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclDeserializer.java @@ -0,0 +1,49 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import java.io.IOException; +import org.apache.polaris.persistence.nosql.authz.api.Acl; +import org.apache.polaris.persistence.nosql.authz.api.AclEntry; + +class AclDeserializer extends JsonDeserializer { + @Override + public Acl deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + if (p.currentToken() != JsonToken.START_OBJECT) { + throw new JsonMappingException(p, "Unexpected token " + p.currentToken()); + } + + var privileges = JacksonPrivilegesModule.currentPrivileges(); + var builder = AclImpl.builder(privileges); + for (var t = p.nextToken(); t != JsonToken.END_OBJECT; t = p.nextToken()) { + if (t == JsonToken.FIELD_NAME) { + var roleId = p.currentName(); + p.nextToken(); + var entry = p.readValueAs(AclEntry.class); + builder.addEntry(roleId, entry); + } + } + return builder.build(); + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclEntryBuilderImpl.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclEntryBuilderImpl.java new file mode 100644 index 0000000000..727ccc5c78 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclEntryBuilderImpl.java @@ -0,0 +1,143 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import jakarta.annotation.Nonnull; +import java.util.Collection; +import org.apache.polaris.persistence.nosql.authz.api.AclEntry; +import org.apache.polaris.persistence.nosql.authz.api.ImmutableAclEntry; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; + +final class AclEntryBuilderImpl implements AclEntry.AclEntryBuilder { + private final PrivilegeSet.PrivilegeSetBuilder granted; + private final PrivilegeSet.PrivilegeSetBuilder restricted; + + AclEntryBuilderImpl(Privileges privileges) { + this.granted = privileges.newPrivilegesSetBuilder(); + this.restricted = privileges.newPrivilegesSetBuilder(); + } + + AclEntryBuilderImpl(Privileges privileges, AclEntry aclEntry) { + this.granted = privileges.newPrivilegesSetBuilder().addPrivileges(aclEntry.granted()); + this.restricted = privileges.newPrivilegesSetBuilder().addPrivileges(aclEntry.restricted()); + } + + @Override + public AclEntry.AclEntryBuilder grant(@Nonnull Privilege privilege) { + this.granted.addPrivilege(privilege); + return this; + } + + @Override + public AclEntry.AclEntryBuilder grant(@Nonnull Privilege... privileges) { + this.granted.addPrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder grant(@Nonnull Collection privileges) { + this.granted.addPrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder grant(@Nonnull PrivilegeSet privileges) { + this.granted.addPrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder revoke(@Nonnull Privilege privilege) { + this.granted.removePrivilege(privilege); + return this; + } + + @Override + public AclEntry.AclEntryBuilder revoke(@Nonnull Privilege... privileges) { + this.granted.removePrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder revoke(@Nonnull Collection privileges) { + this.granted.removePrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder revoke(@Nonnull PrivilegeSet privileges) { + this.granted.removePrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder restrict(@Nonnull Privilege privilege) { + this.restricted.addPrivilege(privilege); + return this; + } + + @Override + public AclEntry.AclEntryBuilder restrict(@Nonnull Privilege... privileges) { + this.restricted.addPrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder restrict(@Nonnull Collection privileges) { + this.restricted.addPrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder restrict(@Nonnull PrivilegeSet privileges) { + this.restricted.addPrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder unrestrict(@Nonnull Privilege privilege) { + this.restricted.removePrivilege(privilege); + return this; + } + + @Override + public AclEntry.AclEntryBuilder unrestrict(@Nonnull Privilege... privileges) { + this.restricted.removePrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder unrestrict(@Nonnull Collection privileges) { + this.restricted.removePrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder unrestrict(@Nonnull PrivilegeSet privileges) { + this.restricted.removePrivileges(privileges); + return this; + } + + @Override + public AclEntry build() { + return ImmutableAclEntry.of(this.granted.build(), this.restricted.build()); + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclImpl.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclImpl.java new file mode 100644 index 0000000000..85359ede3f --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclImpl.java @@ -0,0 +1,132 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import static org.agrona.collections.ObjectHashSet.DEFAULT_INITIAL_CAPACITY; + +import jakarta.annotation.Nonnull; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.agrona.collections.Hashing; +import org.agrona.collections.Object2ObjectHashMap; +import org.apache.polaris.persistence.nosql.authz.api.Acl; +import org.apache.polaris.persistence.nosql.authz.api.AclEntry; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; + +record AclImpl(Object2ObjectHashMap map) implements Acl { + + AclImpl(AclBuilderImpl map) { + this(new Object2ObjectHashMap<>(map.map)); + } + + @Override + public void forEach(@Nonnull BiConsumer consumer) { + map.forEach(consumer); + } + + @Override + public void entriesForRoleIds( + @Nonnull Set roleIds, @Nonnull Consumer aclEntryConsumer) { + roleIds.stream().map(map::get).filter(Objects::nonNull).forEach(aclEntryConsumer); + } + + static AclBuilder builder(Privileges privileges) { + return new AclBuilderImpl(privileges); + } + + @Override + @SuppressWarnings("EqualsGetClass") + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + AclImpl acl = (AclImpl) o; + return map.equals(acl.map); + } + + @Override + @Nonnull + public String toString() { + return "Acl{" + + map.entrySet().stream() + .map(e -> e.getKey() + ":" + e.getValue()) + .collect(Collectors.joining(",")) + + "}"; + } + + private static final class AclBuilderImpl implements AclBuilder { + + private final Object2ObjectHashMap map; + private final Privileges privileges; + + private AclBuilderImpl(Privileges privileges) { + this.privileges = privileges; + this.map = + new Object2ObjectHashMap<>(DEFAULT_INITIAL_CAPACITY, Hashing.DEFAULT_LOAD_FACTOR, false); + } + + @Override + public AclBuilder from(@Nonnull Acl instance) { + map.clear(); + map.putAll(((AclImpl) instance).map); + return this; + } + + @Override + public AclBuilder addEntry(@Nonnull String roleId, @Nonnull AclEntry entry) { + map.put(roleId, entry); + return this; + } + + @Override + public AclBuilder removeEntry(@Nonnull String roleId) { + map.remove(roleId); + return this; + } + + @Override + public AclBuilder modify( + @Nonnull String roleId, @Nonnull Consumer entry) { + map.compute( + roleId, + (k, e) -> { + AclEntry.AclEntryBuilder builder = + e != null + ? new AclEntryBuilderImpl(privileges, e) + : new AclEntryBuilderImpl(privileges); + entry.accept(builder); + AclEntry updated = builder.build(); + return updated.isEmpty() ? null : updated; + }); + return this; + } + + @Override + public Acl build() { + return new AclImpl(this); + } + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclSerializer.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclSerializer.java new file mode 100644 index 0000000000..7033ce5b50 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclSerializer.java @@ -0,0 +1,42 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import org.apache.polaris.persistence.nosql.authz.api.Acl; + +class AclSerializer extends JsonSerializer { + @Override + public void serialize(Acl value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeStartObject(); + value.forEach( + (role, entry) -> { + try { + gen.writeObjectField(role, entry); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + gen.writeEndObject(); + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/JacksonPrivilegesModule.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/JacksonPrivilegesModule.java new file mode 100644 index 0000000000..b5beb49ac0 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/JacksonPrivilegesModule.java @@ -0,0 +1,89 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.fasterxml.jackson.databind.module.SimpleModule; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.inject.spi.CDI; +import java.util.function.Function; +import org.apache.polaris.persistence.nosql.authz.api.Acl; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; + +public class JacksonPrivilegesModule extends SimpleModule { + public JacksonPrivilegesModule() { + addDeserializer(PrivilegeSet.class, new PrivilegeSetDeserializer()); + addSerializer(PrivilegeSet.class, new PrivilegeSetSerializer()); + addDeserializer(Acl.class, new AclDeserializer()); + addSerializer(Acl.class, new AclSerializer()); + } + + static Privileges currentPrivileges() { + return cdiResolve(Privileges.class); + } + + // TODO the following is the same as in AbstractTypeIdResolver + + /** + * Resolve the given type via {@link CDI#current() CDI.current()}. For tests the resolution via + * CDI can be {@linkplain CDIResolver#setResolver(Function) routed to a custom function}. + */ + private static R cdiResolve(Class type) { + // TODO instead of doing the 'CDIResolver' dance, we could (should?) have an attribute in the + // `DatabindContext` holding a reference to the CDI instance (referred to as `Instance`). + var resolved = CDIResolver.resolver.apply(type); + @SuppressWarnings("unchecked") + var r = (R) resolved; + return r; + } + + public static final class CDIResolver { + static Function, ?> resolver = CDIResolver::resolveViaCurrentCDI; + + /** + * The helper function {@link #cdiResolve(Class)} is used by {@link JacksonPrivilegesModule} + * implementations to resolve the {@link Privileges} instance, and the default implementation of + * {@link #cdiResolve(Class)} relies on {@link CDI#current() CDI.current()} to resolve against a + * "singleton" {@link CDI} instance. Some tests do not use CDI. Setting a custom resolver + * function helps in such scenarios. + */ + @SuppressWarnings("unused") + public static void setResolver(Function, ?> resolver) { + CDIResolver.resolver = resolver; + } + + /** + * Manually reset a custom {@linkplain #setResolver(Function) CDI resolver}. This is usually + * performed automatically after each test case. + */ + @SuppressWarnings("unused") + public static void resetResolver() { + resolver = CDIResolver::resolveViaCurrentCDI; + } + + private static Object resolveViaCurrentCDI(Class type) { + Instance selected = CDI.current().select(type); + checkArgument(selected.isResolvable(), "Cannot resolve %s", type); + checkArgument(!selected.isAmbiguous(), "Ambiguous type %s", type); + return selected.get(); + } + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeCheckImpl.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeCheckImpl.java new file mode 100644 index 0000000000..ecda12f50c --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeCheckImpl.java @@ -0,0 +1,75 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import jakarta.annotation.Nonnull; +import java.util.Set; +import org.apache.polaris.persistence.nosql.authz.api.AclChain; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeCheck; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; + +record PrivilegeCheckImpl(Set roleIds, Privileges privileges) implements PrivilegeCheck { + + @Override + public PrivilegeSet effectivePrivilegeSet(@Nonnull AclChain aclChain) { + // Collect granted+restricted from the direct ACL + PrivilegeSet.PrivilegeSetBuilder topGranted = privileges.newPrivilegesSetBuilder(); + PrivilegeSet.PrivilegeSetBuilder topRestricted = privileges.newPrivilegesSetBuilder(); + aclChain + .acl() + .entriesForRoleIds( + roleIds, + aclEntry -> { + topGranted.addPrivileges(aclEntry.granted()); + topRestricted.addPrivileges(aclEntry.restricted()); + }); + + // Collect granted+restricted from the parent ACLs + PrivilegeSet.PrivilegeSetBuilder granted = privileges.newPrivilegesSetBuilder(); + PrivilegeSet.PrivilegeSetBuilder restricted = privileges.newPrivilegesSetBuilder(); + while (aclChain.parent().isPresent()) { + aclChain = aclChain.parent().get(); + aclChain + .acl() + .entriesForRoleIds( + roleIds, + aclEntry -> { + granted.addPrivileges(aclEntry.granted()); + restricted.addPrivileges(aclEntry.restricted()); + }); + } + + // Remove non-inheritable privileges from the ACLs of the parents. Since those are not + // inheritable, they do not apply. + PrivilegeSet nonInheritable = privileges.nonInheritablePrivileges(); + granted.removePrivileges(nonInheritable); + restricted.removePrivileges(nonInheritable); + + // Add all privileges from the "direct" ACL, this includes the non-inheritable privileges + granted.addPrivileges(topGranted.build()); + restricted.addPrivileges(topRestricted.build()); + + // Remove restricted privileges from the granted privileges, `granted` now contains the + // effective privileges + granted.removePrivileges(restricted.build()); + + return granted.build(); + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetDeserializer.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetDeserializer.java new file mode 100644 index 0000000000..a41635ddaf --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetDeserializer.java @@ -0,0 +1,59 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import static org.apache.polaris.persistence.nosql.authz.impl.JacksonPrivilegesModule.currentPrivileges; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import java.io.IOException; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; + +class PrivilegeSetDeserializer extends JsonDeserializer { + @Override + public PrivilegeSet deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + switch (p.currentToken()) { + case VALUE_NULL: + return new PrivilegeSetImpl(currentPrivileges(), new byte[0]); + case VALUE_STRING: + // Internal, storage serialization format. + var bytes = p.getBinaryValue(); + return new PrivilegeSetImpl(currentPrivileges(), bytes); + case START_ARRAY: + // External/REST serialization format using privilege names. + var privileges = currentPrivileges(); + var builder = PrivilegeSetImpl.builder(privileges); + for (var t = p.nextToken(); ; t = p.nextToken()) { + // Note: switch(t) lets checkstyle fail + if (t == JsonToken.VALUE_STRING) { + builder.addPrivilege(privileges.byName(p.getText())); + } + if (t == JsonToken.END_ARRAY) { + break; + } + } + return builder.build(); + default: + throw new JsonMappingException(p, "Unexpected JSON token " + p.currentToken()); + } + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetImpl.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetImpl.java new file mode 100644 index 0000000000..bbcdf09f95 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetImpl.java @@ -0,0 +1,368 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import com.google.common.collect.AbstractIterator; +import jakarta.annotation.Nonnull; +import java.util.Arrays; +import java.util.BitSet; +import java.util.Collection; +import java.util.Iterator; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; + +/** + * Represents a set of {@link Privilege}s, implemented with a bit-map. + * + *

Also provides JSON serializer that is capable of serializing using the privileges in a + * space-efficient binary format (the bit-map, if the current JSON view is {@code StorageView}), and + * a verbose textual representation (for "external" serialization). + */ +record PrivilegeSetImpl(Privileges privileges, byte[] bytes) implements PrivilegeSet { + private PrivilegeSetImpl(Privileges privileges, PrivilegeSetBuilderImpl builder) { + this(privileges, builder.bitSet.toByteArray()); + } + + @Override + public int size() { + var size = 0; + for (var b : bytes) { + var i = b & 0xFF; + size += Integer.bitCount(i); + } + return size; + } + + @Override + public boolean isEmpty() { + return bytes.length == 0; + } + + @Override + public byte[] toByteArray() { + return Arrays.copyOf(bytes, bytes.length); + } + + @SuppressWarnings("NullableProblems") + @Override + public Object[] toArray() { + return toArray(new Privilege[0]); + } + + @SuppressWarnings("NullableProblems") + @Override + public T[] toArray(T[] a) { + var size = size(); + var arrType = requireNonNull(a).getClass().getComponentType(); + checkArgument(arrType.isAssignableFrom(Privilege.class)); + var arr = (Object[]) a; + if (arr.length < size) { + arr = Arrays.copyOf(a, size); + } + + var i = 0; + for (Privilege privilege : this) { + arr[i++] = privilege; + } + + @SuppressWarnings("unchecked") + var r = (T[]) arr; + return r; + } + + @Override + public boolean add(Privilege privilege) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean remove(Object o) { + throw new UnsupportedOperationException(); + } + + @SuppressWarnings("NullableProblems") + @Override + public boolean addAll(Collection c) { + throw new UnsupportedOperationException(); + } + + @SuppressWarnings("NullableProblems") + @Override + public boolean retainAll(Collection c) { + throw new UnsupportedOperationException(); + } + + @SuppressWarnings("NullableProblems") + @Override + public boolean removeAll(Collection c) { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean contains(Object privilege) { + return privilege instanceof Privilege p && contains(p); + } + + @Override + public boolean contains(Privilege privilege) { + return privilege.mustMatchAll() + ? containsMustMatchAll(privilege) + : containsMustMatchAny(privilege); + } + + private boolean containsMustMatchAll(Privilege privilege) { + var arr = this.bytes; + for (Privilege.IndividualPrivilege p : privilege.resolved()) { + var id = privileges.idForName(p.name()); + var index = byteIndex(id); + if (arr.length <= index) { + return false; + } + var v = arr[index]; + var mask = mask(id); + if ((v & mask) != mask) { + return false; + } + } + return true; + } + + private boolean containsMustMatchAny(Privilege privilege) { + var arr = this.bytes; + for (var p : privilege.resolved()) { + var id = privileges.idForName(p.name()); + var index = byteIndex(id); + if (arr.length <= index) { + continue; + } + var v = arr[index]; + var mask = mask(id); + if ((v & mask) == mask) { + return true; + } + } + return false; + } + + @Override + @SuppressWarnings("PatternMatchingInstanceof") + public boolean containsAll(@Nonnull Collection privileges) { + for (var o : privileges) { + if (!(o instanceof Privilege)) { + return false; + } + if (!contains((Privilege) o)) { + return false; + } + } + return true; + } + + @Override + public boolean containsAny(Iterable privileges) { + for (var o : privileges) { + if (contains(o)) { + return true; + } + } + return false; + } + + @SuppressWarnings("NullableProblems") + @Override + public Iterator iterator() { + return iterator(privileges); + } + + @Override + public Iterator iterator(Privileges privileges) { + return new AbstractIterator<>() { + private int idx = 0; + + @Override + protected Privilege computeNext() { + while (true) { + var i = idx++; + var arrIdx = byteIndex(i); + if (arrIdx >= PrivilegeSetImpl.this.bytes.length) { + return endOfData(); + } + var mask = mask(i); + var v = PrivilegeSetImpl.this.bytes[arrIdx]; + if ((v & mask) == mask) { + return privileges.byId(i); + } + } + } + }; + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + + @Override + @SuppressWarnings("EqualsGetClass") + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + PrivilegeSetImpl privilegeSet = (PrivilegeSetImpl) o; + return Arrays.equals(privilegeSet.bytes, bytes); + } + + @Nonnull + @Override + public String toString() { + var id = 0; + var sb = new StringBuilder("PrivilegeSet{"); + var first = true; + for (var b : this.bytes) { + for (int i = 0, m = 1; i < 8; i++, m <<= 1) { + if ((b & m) != 0) { + if (first) { + first = false; + } else { + sb.append(','); + } + sb.append(id); + } + id++; + } + } + return sb.append('}').toString(); + } + + static int byteIndex(int id) { + return id >> 3; + } + + static byte mask(int id) { + return (byte) (1 << (id & 7)); + } + + static PrivilegeSetBuilder builder(Privileges privileges) { + return new PrivilegeSetBuilderImpl(privileges); + } + + byte[] bytesUnsafe() { + return bytes; + } + + static final class PrivilegeSetBuilderImpl implements PrivilegeSetBuilder { + private final BitSet bitSet; + private final Privileges privileges; + + private PrivilegeSetBuilderImpl(Privileges privileges) { + this.bitSet = new BitSet(); + this.privileges = privileges; + } + + @Override + public PrivilegeSetBuilder addPrivileges(@Nonnull Iterable privileges) { + for (var privilege : privileges) { + addPrivilege(privilege); + } + return this; + } + + @Override + public PrivilegeSetBuilder addPrivileges(@Nonnull Privilege... privileges) { + for (var privilege : privileges) { + addPrivilege(privilege); + } + return this; + } + + @Override + public PrivilegeSetBuilder addPrivileges(@Nonnull PrivilegeSet privilegeSet) { + var bytes = + (privilegeSet instanceof PrivilegeSetImpl privilegeSetImpl) + ? privilegeSetImpl.bytes + : privilegeSet.toByteArray(); + // TODO `valueOf(byte[])` is way more expensive than `valueOf(long[])` + bitSet.or(BitSet.valueOf(bytes)); + return this; + } + + @Override + public PrivilegeSetBuilder addPrivilege(@Nonnull Privilege privilege) { + for (var individualPrivilege : privilege.resolved()) { + var id = privileges.idForName(individualPrivilege.name()); + this.bitSet.set(id); + } + return this; + } + + @Override + public PrivilegeSetBuilder removePrivileges(@Nonnull Iterable privileges) { + for (var privilege : privileges) { + removePrivilege(privilege); + } + return this; + } + + @Override + public PrivilegeSetBuilder removePrivileges(@Nonnull Privilege... privileges) { + for (var privilege : privileges) { + removePrivilege(privilege); + } + return this; + } + + @Override + public PrivilegeSetBuilder removePrivileges(@Nonnull PrivilegeSet privilegeSet) { + var bytes = + (privilegeSet instanceof PrivilegeSetImpl privilegeSetImpl) + ? privilegeSetImpl.bytes + : privilegeSet.toByteArray(); + // TODO `valueOf(byte[])` is way more expensive than `valueOf(long[])` + bitSet.andNot(BitSet.valueOf(bytes)); + return this; + } + + @Override + public PrivilegeSetBuilder removePrivilege(@Nonnull Privilege privilege) { + for (var individualPrivilege : privilege.resolved()) { + var id = privileges.idForName(individualPrivilege.name()); + this.bitSet.clear(id); + } + return this; + } + + @Override + public PrivilegeSet build() { + return new PrivilegeSetImpl(privileges, this); + } + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetSerializer.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetSerializer.java new file mode 100644 index 0000000000..1e7d5b65aa --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetSerializer.java @@ -0,0 +1,55 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import static org.apache.polaris.persistence.nosql.authz.impl.JacksonPrivilegesModule.currentPrivileges; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; + +public class PrivilegeSetSerializer extends JsonSerializer { + @Override + public void serialize(PrivilegeSet value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + var view = serializers.getActiveView(); + if (view != null && view.getSimpleName().equals("StorageView")) { + // When serializing for/to persistence as a use the bit-encoded/binary + // serialization. This is triggered when the current Jackson view is + // `org.apache.polaris.persistence.nosql.api.obj.Obj.StorageView`. + if (!value.isEmpty()) { + var impl = (PrivilegeSetImpl) value; + gen.writeBinary(impl.bytesUnsafe()); + } else { + gen.writeNull(); + } + } else { + // Otherwise, for external/REST, use the privilege names from the "global" set of + // privileges. + gen.writeStartArray(); + var collapsed = value.collapseComposites(currentPrivileges()); + for (var privilege : collapsed) { + gen.writeString(privilege.name()); + } + gen.writeEndArray(); + } + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesImpl.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesImpl.java new file mode 100644 index 0000000000..b05d23fba1 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesImpl.java @@ -0,0 +1,256 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.lang.String.format; +import static java.util.Collections.unmodifiableSet; +import static java.util.Objects.requireNonNull; +import static org.agrona.collections.ObjectHashSet.DEFAULT_INITIAL_CAPACITY; +import static org.apache.polaris.persistence.nosql.authz.api.PredefinedRoles.ANONYMOUS_ROLE; +import static org.apache.polaris.persistence.nosql.authz.api.PredefinedRoles.PUBLIC_ROLE; + +import com.google.common.annotations.VisibleForTesting; +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.agrona.collections.Hashing; +import org.agrona.collections.Int2ObjectHashMap; +import org.agrona.collections.Object2ObjectHashMap; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.authz.api.Acl; +import org.apache.polaris.persistence.nosql.authz.api.AclEntry; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeCheck; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; +import org.apache.polaris.persistence.nosql.authz.spi.ImmutablePrivilegesMapping; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegeDefinition; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesProvider; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesRepository; +import org.immutables.value.Value; + +@ApplicationScoped +class PrivilegesImpl implements Privileges { + private final Object2ObjectHashMap nameToPrivilege; + private final Int2ObjectHashMap idToPrivilege; + private final PrivilegeSet nonInheritablePrivileges; + private final Privilege.CompositePrivilege[] compositePrivileges; + private final Collection allPrivileges; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + PrivilegesImpl( + Instance privilegesProviders, PrivilegesRepository privilegesRepository) { + this(privilegesProviders.stream(), privilegesRepository); + } + + @VisibleForTesting + PrivilegesImpl(Stream stream, PrivilegesRepository privilegesRepository) { + var providedPrivileges = + new HashMap>(); + for (var providerIter = stream.iterator(); providerIter.hasNext(); ) { + var privilegesProvider = providerIter.next(); + for (var definitions = privilegesProvider.privilegeDefinitions().iterator(); + definitions.hasNext(); ) { + var definition = definitions.next(); + var duplicate = + providedPrivileges.putIfAbsent( + definition.privilege().name(), Map.entry(definition, privilegesProvider)); + if (duplicate != null) { + throw new IllegalStateException( + format( + "Duplicate privilege definition for name '%s'", definition.privilege().name())); + } + } + } + + var individualPrivileges = + providedPrivileges.values().stream() + .map(Map.Entry::getKey) + .map(PrivilegeDefinition::privilege) + .filter(Privilege.IndividualPrivilege.class::isInstance) + .toList(); + var individualPrivilegeNames = + individualPrivileges.stream().map(Privilege::name).collect(Collectors.toSet()); + + while (true) { + var mapping = privilegesRepository.fetchPrivilegesMapping(); + var mapped = mapping.nameToId(); + var maxId = mapped.values().stream().max(Integer::compareTo).orElse(-1); + + if (!mapped.keySet().containsAll(individualPrivilegeNames)) { + // not all individual privileges have an integer ID - need to persist an updated version of + // the privilege name-to-id mapping! + + var existingNames = mapped.keySet(); + var namesToMap = new HashSet<>(individualPrivilegeNames); + namesToMap.removeAll(existingNames); + + var newMappingBuilder = ImmutablePrivilegesMapping.builder(); + newMappingBuilder.putAllNameToId(mapped); + for (var nameToMap : namesToMap) { + newMappingBuilder.putNameToId(nameToMap, ++maxId); + } + + var newMapping = newMappingBuilder.build(); + if (privilegesRepository.updatePrivilegesMapping(mapping, newMapping)) { + // our update worked, go ahead + mapped = newMapping.nameToId(); + } else { + // oops, a race with a concurrently starting instance, retry... + continue; + } + } + + // At this point we know that all individual privileges have a valid and persisted + // constant integer ID + this.nameToPrivilege = + new Object2ObjectHashMap<>(DEFAULT_INITIAL_CAPACITY, Hashing.DEFAULT_LOAD_FACTOR, false); + this.idToPrivilege = + new Int2ObjectHashMap<>(DEFAULT_INITIAL_CAPACITY, Hashing.DEFAULT_LOAD_FACTOR, false); + var nonInheritablePrivilegesBuilder = PrivilegeSetImpl.builder(this); + var compositePrivilegesBuilder = new ArrayList(); + for (var provided : providedPrivileges.values()) { + var privilege = provided.getKey().privilege(); + var name = privilege.name(); + var id = (int) requireNonNull(mapped.getOrDefault(name, -1)); + this.nameToPrivilege.put(name, ImmutablePrivilegeAndId.of(privilege, id)); + if (id != -1) { + this.idToPrivilege.put(id, ImmutablePrivilegeAndId.of(privilege, id)); + } + if (privilege instanceof Privilege.NonInheritablePrivilege nonInheritablePrivilege) { + nonInheritablePrivilegesBuilder.addPrivilege(nonInheritablePrivilege); + } else if (privilege instanceof Privilege.CompositePrivilege compositePrivilege) { + compositePrivilegesBuilder.add(compositePrivilege); + } + } + this.nonInheritablePrivileges = nonInheritablePrivilegesBuilder.build(); + this.compositePrivileges = + compositePrivilegesBuilder.toArray(Privilege.CompositePrivilege[]::new); + this.allPrivileges = + nameToPrivilege.values().stream().map(PrivilegeAndId::privilege).toList(); + + break; + } + } + + @PolarisImmutable + interface PrivilegeAndId { + @Value.Parameter + Privilege privilege(); + + @Value.Parameter + int id(); + } + + @Override + public Privilege byName(@Nonnull String name) { + var ex = nameToPrivilege.get(name); + checkArgument(ex != null, "Unknown privilege '%s'", name); + return ex.privilege(); + } + + @Override + public Privilege byId(int id) { + var ex = idToPrivilege.get(id); + checkArgument(ex != null, "Unknown privilege ID %s", id); + return ex.privilege(); + } + + @Override + public int idForName(@Nonnull String name) { + var ex = nameToPrivilege.get(name); + checkArgument(ex != null && ex.id() >= 0, "Unknown individual privilege '%s'", name); + return ex.id(); + } + + @Override + public int idForPrivilege(@Nonnull Privilege privilege) { + return idForName(privilege.name()); + } + + @Override + public Set allNames() { + return unmodifiableSet(nameToPrivilege.keySet()); + } + + @Override + public Set allIds() { + return unmodifiableSet(idToPrivilege.keySet()); + } + + @Override + public PrivilegeSet nonInheritablePrivileges() { + return nonInheritablePrivileges; + } + + @Override + public Set collapseComposites(@Nonnull PrivilegeSet value) { + Set collapsed = new HashSet<>(); + + var work = newPrivilegesSetBuilder().addPrivileges(value); + for (Privilege.CompositePrivilege compositePrivilege : compositePrivileges) { + if (value.contains(compositePrivilege)) { + work.removePrivileges(compositePrivilege); + collapsed.add(compositePrivilege); + } + } + + collapsed.addAll(work.build()); + + return collapsed; + } + + @Override + public Collection all() { + return allPrivileges; + } + + @Override + public PrivilegeSet.PrivilegeSetBuilder newPrivilegesSetBuilder() { + return PrivilegeSetImpl.builder(this); + } + + @Override + public AclEntry.AclEntryBuilder newAclEntryBuilder() { + return new AclEntryBuilderImpl(this); + } + + @Override + public Acl.AclBuilder newAclBuilder() { + return AclImpl.builder(this); + } + + @Override + public PrivilegeCheck startPrivilegeCheck(boolean anonymous, Set roleIds) { + Set effectiveRoles = new HashSet<>(roleIds); + effectiveRoles.add(anonymous ? ANONYMOUS_ROLE : PUBLIC_ROLE); + return new PrivilegeCheckImpl(effectiveRoles, this); + } +} diff --git a/persistence/nosql/authz/impl/src/main/resources/META-INF/beans.xml b/persistence/nosql/authz/impl/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/authz/impl/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module b/persistence/nosql/authz/impl/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module new file mode 100644 index 0000000000..4225dfcfee --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module @@ -0,0 +1,20 @@ +# +# 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. +# + +org.apache.polaris.persistence.nosql.authz.impl.JacksonPrivilegesModule diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestProvider.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestProvider.java new file mode 100644 index 0000000000..cae209157e --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestProvider.java @@ -0,0 +1,84 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.AlternativePrivilege.alternativePrivilege; +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.CompositePrivilege.compositePrivilege; +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.InheritablePrivilege.inheritablePrivilege; +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.NonInheritablePrivilege.nonInheritablePrivilege; + +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegeDefinition; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesProvider; + +class PrivilegesTestProvider implements PrivilegesProvider { + @Override + public String name() { + return "TEST-ONLY Privileges"; + } + + @Override + public Stream privilegeDefinitions() { + var zero = inheritablePrivilege("zero"); + var one = inheritablePrivilege("one"); + var two = inheritablePrivilege("two"); + var three = inheritablePrivilege("three"); + var four = inheritablePrivilege("four"); + var five = inheritablePrivilege("five"); + var six = inheritablePrivilege("six"); + var seven = inheritablePrivilege("seven"); + var eight = inheritablePrivilege("eight"); + var nine = inheritablePrivilege("nine"); + var nonInherit = nonInheritablePrivilege("nonInherit"); + var oneTwoThree = compositePrivilege("oneTwoThree", one, two, three); + var duplicateOneTwoThree = compositePrivilege("duplicateOneTwoThree", one, two, three); + var twoThreeFour = compositePrivilege("twoThreeFour", two, three, four); + var fiveSix = compositePrivilege("fiveSix", five, six); + var zeroTwo = alternativePrivilege("zeroTwo", zero, two); + var eightNine = alternativePrivilege("eightNine", eight, nine); + var individuals = + Stream.of( + zero, + one, + two, + three, + four, + five, + six, + seven, + eight, + nine, + nonInherit, + oneTwoThree, + duplicateOneTwoThree, + twoThreeFour, + fiveSix, + zeroTwo, + eightNine); + + var manyFoo = + IntStream.range(0, 128) + .mapToObj(id -> Privilege.InheritablePrivilege.inheritablePrivilege("foo_" + id)); + + return Stream.concat(individuals, manyFoo) + .map(p -> PrivilegeDefinition.builder().privilege(p).build()); + } +} diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestRepository.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestRepository.java new file mode 100644 index 0000000000..fbed9e8b34 --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestRepository.java @@ -0,0 +1,43 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import jakarta.annotation.Nonnull; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesMapping; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesRepository; + +class PrivilegesTestRepository implements PrivilegesRepository { + private final AtomicReference current = + new AtomicReference<>(PrivilegesMapping.builder().build()); + + @Override + public boolean updatePrivilegesMapping( + @Nonnull PrivilegesMapping expectedState, @Nonnull PrivilegesMapping newState) { + var v = current.updateAndGet(curr -> curr.equals(expectedState) ? newState : curr); + return v.equals(newState); + } + + @Override + @Nonnull + public PrivilegesMapping fetchPrivilegesMapping() { + return Optional.ofNullable(current.get()).orElse(PrivilegesMapping.EMPTY); + } +} diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclEntryImpl.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclEntryImpl.java new file mode 100644 index 0000000000..0fec66b362 --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclEntryImpl.java @@ -0,0 +1,73 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.authz.api.AclEntry; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestAclEntryImpl { + @InjectSoftAssertions SoftAssertions soft; + + private static ObjectMapper mapper; + private static PrivilegesImpl privileges; + + @BeforeAll + static void setUp() { + mapper = new ObjectMapper().findAndRegisterModules(); + privileges = + new PrivilegesImpl(Stream.of(new PrivilegesTestProvider()), new PrivilegesTestRepository()); + JacksonPrivilegesModule.CDIResolver.setResolver(x -> privileges); + } + + @ParameterizedTest + @MethodSource + public void aclEntry(AclEntry aclEntry) throws Exception { + + String json = mapper.writeValueAsString(aclEntry); + soft.assertThat(mapper.readValue(json, AclEntry.class)).isEqualTo(aclEntry); + } + + static Stream aclEntry() { + Privilege zero = privileges.byId(0); + Privilege eight = privileges.byId(8); + Privilege nine = privileges.byId(9); + return Stream.of( + privileges.newAclEntryBuilder().build(), + privileges.newAclEntryBuilder().grant(zero).build(), + privileges.newAclEntryBuilder().restrict(zero).build(), + privileges.newAclEntryBuilder().grant(zero).restrict(zero).build(), + privileges.newAclEntryBuilder().grant(zero, eight, nine).build(), + privileges.newAclEntryBuilder().restrict(zero, eight, nine).build(), + privileges + .newAclEntryBuilder() + .grant(zero, eight, nine) + .restrict(zero, eight, nine) + .build()); + } +} diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclImpl.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclImpl.java new file mode 100644 index 0000000000..552141279e --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclImpl.java @@ -0,0 +1,94 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.authz.api.Acl; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestAclImpl { + @SuppressWarnings("VisibilityModifier") + @InjectSoftAssertions + protected SoftAssertions soft; + + private static ObjectMapper mapper; + private static PrivilegesImpl privileges; + + @BeforeAll + static void setUp() { + mapper = new ObjectMapper().findAndRegisterModules(); + privileges = + new PrivilegesImpl(Stream.of(new PrivilegesTestProvider()), new PrivilegesTestRepository()); + JacksonPrivilegesModule.CDIResolver.setResolver(x -> privileges); + } + + @ParameterizedTest + @MethodSource + public void acl(Acl acl) throws Exception { + String json = mapper.writeValueAsString(acl); + soft.assertThat(mapper.readValue(json, Acl.class)).isEqualTo(acl); + } + + static Stream acl() { + return Stream.of( + privileges.newAclBuilder().build(), + privileges.newAclBuilder().addEntry("one", privileges.newAclEntryBuilder().build()).build(), + privileges + .newAclBuilder() + .addEntry("one", privileges.newAclEntryBuilder().build()) + .addEntry("two", privileges.newAclEntryBuilder().build()) + .addEntry("three", privileges.newAclEntryBuilder().build()) + .build(), + privileges + .newAclBuilder() + .addEntry("oneTwoThree", privileges.newAclEntryBuilder().build()) + .build(), + privileges + .newAclBuilder() + .addEntry( + "one", + privileges + .newAclEntryBuilder() + .grant(Privilege.InheritablePrivilege.inheritablePrivilege("zero")) + .build()) + .addEntry( + "two", + privileges + .newAclEntryBuilder() + .grant(Privilege.InheritablePrivilege.inheritablePrivilege("zero")) + .build()) + .addEntry( + "three", + privileges + .newAclEntryBuilder() + .grant(Privilege.InheritablePrivilege.inheritablePrivilege("zero")) + .restrict(Privilege.InheritablePrivilege.inheritablePrivilege("zero")) + .build()) + .build()); + } +} diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeCheckImpl.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeCheckImpl.java new file mode 100644 index 0000000000..47084d0593 --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeCheckImpl.java @@ -0,0 +1,228 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.authz.api.AclChain; +import org.apache.polaris.persistence.nosql.authz.api.AclEntry; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeCheck; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestPrivilegeCheckImpl { + @SuppressWarnings("VisibilityModifier") + @InjectSoftAssertions + protected SoftAssertions soft; + + private static PrivilegesImpl privileges; + + @BeforeAll + static void setUp() { + privileges = + new PrivilegesImpl(Stream.of(new PrivilegesTestProvider()), new PrivilegesTestRepository()); + JacksonPrivilegesModule.CDIResolver.setResolver(x -> privileges); + } + + static PrivilegeSet privilegeSet(Privilege... values) { + return privileges.newPrivilegesSetBuilder().addPrivileges(values).build(); + } + + static AclEntry aclEntry(PrivilegeSet granted, PrivilegeSet restricted) { + return privileges.newAclEntryBuilder().grant(granted).restrict(restricted).build(); + } + + @Test + public void restricted() { + Privilege zero = privileges.byName("zero"); + Privilege two = privileges.byName("two"); + Privilege four = privileges.byName("four"); + Privilege nine = privileges.byName("nine"); + Privilege zeroTwo = privileges.byName("zeroTwo"); + Privilege eightNine = privileges.byName("eightNine"); + + AclChain root = + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry( + "root-g0-r0", + privileges.newAclEntryBuilder().grant(zero).restrict(zero).build()) + .addEntry("root-g0", privileges.newAclEntryBuilder().grant(zero).build()) + .addEntry("root-r0", privileges.newAclEntryBuilder().restrict(zero).build()) + .addEntry("root-r4", privileges.newAclEntryBuilder().restrict(four).build()) + .addEntry("x-0", privileges.newAclEntryBuilder().grant(zero).build()) + .build(), + Optional.empty()); + AclChain parent = + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry( + "parent-g2-r2", + privileges.newAclEntryBuilder().grant(two).restrict(two).grant().build()) + .addEntry("parent-g2", privileges.newAclEntryBuilder().grant(two).grant().build()) + .addEntry("parent-r2", privileges.newAclEntryBuilder().restrict(two).build()) + .addEntry("parent-r0", privileges.newAclEntryBuilder().restrict(zero).build()) + .addEntry("x-0", privileges.newAclEntryBuilder().restrict(zero).build()) + .build(), + Optional.of(root)); + AclChain leaf = + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry( + "leaf-g4-r4", + privileges.newAclEntryBuilder().grant(four).restrict(four).build()) + .addEntry("leaf-g4", privileges.newAclEntryBuilder().grant(four).build()) + .build(), + Optional.of(parent)); + + PrivilegeCheck privilegeCheck = + privileges.startPrivilegeCheck(false, Collections.singleton("root-g0")); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(zero)).isTrue(); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(nine)).isFalse(); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(zeroTwo)).isTrue(); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(eightNine)).isFalse(); + + privilegeCheck = privileges.startPrivilegeCheck(false, Set.of("root-g0", "root-r0")); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(zero)).isFalse(); + privilegeCheck = privileges.startPrivilegeCheck(false, Set.of("root-g0-r0")); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(zero)).isFalse(); + + privilegeCheck = privileges.startPrivilegeCheck(false, Set.of("leaf-g4")); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(four)).isTrue(); + + privilegeCheck = privileges.startPrivilegeCheck(false, Set.of("leaf-g4", "root-r4")); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(four)).isFalse(); + + privilegeCheck = privileges.startPrivilegeCheck(false, Set.of("x-0")); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(zero)).isFalse(); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(parent).contains(zero)).isFalse(); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(root).contains(zero)).isTrue(); + } + + @ParameterizedTest + @MethodSource + public void nonInheritablePrivilegeOnTop(AclChain aclChain, PrivilegeSet expectedEffective) { + PrivilegeCheck privilegeCheck = privileges.startPrivilegeCheck(false, Set.of("user")); + + PrivilegeSet effective = privilegeCheck.effectivePrivilegeSet(aclChain); + soft.assertThat(effective).isEqualTo(expectedEffective); + } + + static Stream nonInheritablePrivilegeOnTop() { + Privilege one = privileges.byName("one"); + Privilege two = privileges.byName("two"); + Privilege three = privileges.byName("three"); + Privilege nonInherit = privileges.byName("nonInherit"); + + return Stream.of( + arguments( + // top + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(one, nonInherit), privilegeSet())) + .build(), + // mid + Optional.of( + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(two), privilegeSet())) + .build(), + // root + Optional.of( + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(three), privilegeSet())) + .build(), + Optional.empty()))))), + privilegeSet(one, two, three, nonInherit)), + // + arguments( + // top + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(one), privilegeSet())) + .build(), + // mid + Optional.of( + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry( + "user", aclEntry(privilegeSet(two, nonInherit), privilegeSet())) + .build(), + // root + Optional.of( + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(three), privilegeSet())) + .build(), + Optional.empty()))))), + privilegeSet(one, two, three)), + // + arguments( + // top + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(one), privilegeSet())) + .build(), + // mid + Optional.of( + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(two), privilegeSet())) + .build(), + // root + Optional.of( + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry( + "user", + aclEntry(privilegeSet(three, nonInherit), privilegeSet())) + .build(), + Optional.empty()))))), + privilegeSet(one, two, three)) + // + ); + } +} diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeSetImpl.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeSetImpl.java new file mode 100644 index 0000000000..2832973f39 --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeSetImpl.java @@ -0,0 +1,217 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import static java.util.Collections.singleton; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import java.util.ArrayList; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestPrivilegeSetImpl { + @SuppressWarnings("VisibilityModifier") + @InjectSoftAssertions + protected SoftAssertions soft; + + private static ObjectMapper mapper; + private static PrivilegesImpl privileges; + + // Needed for tests, don't want to pull in polaris-persistence-nosql-api just for this test + static final class StorageView {} + + @BeforeAll + static void setUp() { + mapper = new ObjectMapper().findAndRegisterModules(); + privileges = + new PrivilegesImpl(Stream.of(new PrivilegesTestProvider()), new PrivilegesTestRepository()); + JacksonPrivilegesModule.CDIResolver.setResolver(x -> privileges); + } + + @SuppressWarnings("RedundantCollectionOperation") + @ParameterizedTest + @MethodSource + public void singlePrivileges(Privilege.IndividualPrivilege privilege) throws Exception { + var privilegeSet = privileges.newPrivilegesSetBuilder().addPrivilege(privilege).build(); + soft.assertThat(privilegeSet.isEmpty()).isFalse(); + soft.assertThat(privilegeSet.contains(privilege)).isTrue(); + soft.assertThat(privilegeSet.containsAll(singleton(privilege))).isTrue(); + soft.assertThat( + privileges + .newPrivilegesSetBuilder() + .addPrivileges(privilegeSet) + .build() + .contains(privilege)) + .isTrue(); + soft.assertThat( + privileges + .newPrivilegesSetBuilder() + .addPrivileges(privilegeSet) + .removePrivilege(privilege) + .build() + .contains(privilege)) + .isFalse(); + + var index = privileges.idForPrivilege(privilege) >> 3; + soft.assertThat(privilegeSet.toByteArray()).hasSize(index + 1); + soft.assertThat( + privileges + .newPrivilegesSetBuilder() + .addPrivileges(privilegeSet) + .removePrivilege(privilege) + .build() + .toByteArray()) + .hasSize(0); + soft.assertThat( + privileges + .newPrivilegesSetBuilder() + .addPrivileges(privilegeSet) + .removePrivilege(privilege) + .build() + .isEmpty()) + .isTrue(); + + var writer = mapper.writerWithView(StorageView.class); + var json = writer.writeValueAsString(privilegeSet); + soft.assertThat(mapper.readValue(json, PrivilegeSet.class)).isEqualTo(privilegeSet); + + var jsonNode = mapper.readValue(json, JsonNode.class); + soft.assertThat(jsonNode.isArray()).isFalse(); + + for (var j = 0; j < 256; j++) { + if (j == privileges.idForPrivilege(privilege)) { + continue; + } + var other = Privilege.InheritablePrivilege.inheritablePrivilege("zero"); + soft.assertThat(privilegeSet.contains(other)).isFalse(); + soft.assertThat(privilegeSet.containsAll(singleton(other))).isFalse(); + soft.assertThat( + privileges + .newPrivilegesSetBuilder() + .addPrivileges(privilegeSet) + .build() + .contains(other)) + .isFalse(); + soft.assertThat( + privileges + .newPrivilegesSetBuilder() + .addPrivileges(privilegeSet) + .addPrivilege(other) + .build() + .contains(other)) + .isTrue(); + } + } + + static Stream singlePrivileges() { + return IntStream.range(0, 128) + .mapToObj(id -> Privilege.InheritablePrivilege.inheritablePrivilege("foo_" + id)); + } + + @ParameterizedTest + @MethodSource + public void nameSerialization(PrivilegeSet privilegeSet) throws Exception { + var json = mapper.writeValueAsString(privilegeSet); + + var deserialized = mapper.readValue(json, PrivilegeSet.class); + soft.assertThat(deserialized).isEqualTo(privilegeSet); + soft.assertThat(deserialized).containsExactlyInAnyOrderElementsOf(privilegeSet); + + var jsonNode = mapper.readValue(json, JsonNode.class); + soft.assertThat(jsonNode.isArray()).isTrue(); + } + + static Stream nameSerialization() { + return Stream.concat( + privileges.all().stream() + .map(p -> privileges.newPrivilegesSetBuilder().addPrivilege(p).build()), + Stream.of(privileges.newPrivilegesSetBuilder().addPrivileges(privileges.all()).build())); + } + + @ParameterizedTest + @MethodSource + public void compositeByNameSerialization( + Privilege composite, Set more, Set inJson) throws Exception { + var privilegeSet = + privileges.newPrivilegesSetBuilder().addPrivilege(composite).addPrivileges(more).build(); + + var json = mapper.writeValueAsString(privilegeSet); + + var deserialized = mapper.readValue(json, PrivilegeSet.class); + soft.assertThat(deserialized).isEqualTo(privilegeSet); + soft.assertThat(deserialized).containsAll(composite.resolved()); + soft.assertThat(deserialized.containsAll(composite.resolved())).isTrue(); + soft.assertThat(deserialized.containsAll(more)).isTrue(); + + var jsonNode = mapper.readValue(json, JsonNode.class); + soft.assertThat(jsonNode.isArray()).isTrue(); + + var values = new ArrayList(); + var arrayNode = (ArrayNode) jsonNode; + for (var i = 0; i < arrayNode.size(); i++) { + values.add(arrayNode.get(i).asText()); + } + + soft.assertThat(values) + .containsExactlyInAnyOrderElementsOf( + inJson.stream().map(Privilege::name).collect(Collectors.toList())); + } + + static Stream compositeByNameSerialization() { + var oneTwoThree = privileges.byName("oneTwoThree"); + var duplicateOneTwoThree = privileges.byName("duplicateOneTwoThree"); + var twoThreeFour = privileges.byName("twoThreeFour"); + var fiveSix = privileges.byName("fiveSix"); + var three = privileges.byName("three"); + var five = privileges.byName("five"); + var seven = privileges.byName("seven"); + + return Stream.of( + arguments(oneTwoThree, Set.of(), Set.of(oneTwoThree, duplicateOneTwoThree)), + arguments( + oneTwoThree, + Set.of(three, five, seven), + Set.of(oneTwoThree, duplicateOneTwoThree, five, seven)), + arguments(duplicateOneTwoThree, Set.of(), Set.of(oneTwoThree, duplicateOneTwoThree)), + arguments(twoThreeFour, Set.of(), Set.of(twoThreeFour)), + arguments(twoThreeFour, Set.of(three, five, seven), Set.of(twoThreeFour, five, seven)), + arguments(fiveSix, Set.of(), Set.of(fiveSix)), + arguments(fiveSix, Set.of(three, five, seven), Set.of(fiveSix, three, seven)), + arguments( + twoThreeFour, + Set.of(oneTwoThree), + Set.of(oneTwoThree, duplicateOneTwoThree, twoThreeFour))); + } +} diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegesImpl.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegesImpl.java new file mode 100644 index 0000000000..df9b8a388b --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegesImpl.java @@ -0,0 +1,143 @@ +/* + * 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.polaris.persistence.nosql.authz.impl; + +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.AlternativePrivilege.alternativePrivilege; +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.CompositePrivilege.compositePrivilege; +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.InheritablePrivilege.inheritablePrivilege; + +import java.util.Set; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegeDefinition; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesProvider; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.assertj.core.util.Lists; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestPrivilegesImpl { + @SuppressWarnings("VisibilityModifier") + @InjectSoftAssertions + protected SoftAssertions soft; + + @Test + public void duplicateNames() { + soft.assertThatIllegalStateException() + .isThrownBy( + () -> + testPrivileges( + testPrivilegesProvider( + "PROVIDER1", inheritablePrivilege("foo"), inheritablePrivilege("foo")))) + .withMessage("Duplicate privilege definition for name 'foo'"); + + soft.assertThatIllegalStateException() + .isThrownBy( + () -> + testPrivileges( + testPrivilegesProvider("PROVIDER1", inheritablePrivilege("foo")), + testPrivilegesProvider("PROVIDER2", inheritablePrivilege("foo")))) + .withMessage("Duplicate privilege definition for name 'foo'"); + } + + static PrivilegesProvider testPrivilegesProvider(String name, Privilege... privileges) { + return new PrivilegesProvider() { + @Override + public Stream privilegeDefinitions() { + return Stream.of(privileges).map(p -> PrivilegeDefinition.builder().privilege(p).build()); + } + + @Override + public String name() { + return name; + } + }; + } + + static Privileges testPrivileges(PrivilegesProvider... privilegeProviders) { + var privilegesRepository = new PrivilegesTestRepository(); + return new PrivilegesImpl(Stream.of(privilegeProviders), privilegesRepository); + } + + @Test + public void compositeAndAlternativePrivileges() { + var foo = inheritablePrivilege("foo"); + var bar = inheritablePrivilege("bar"); + var baz = inheritablePrivilege("baz"); + var fooBarBaz = compositePrivilege("foo-bar-baz", foo, bar, baz); + var alt1 = alternativePrivilege("alt1", foo, bar); + + var meow = inheritablePrivilege("meow"); + var woof = inheritablePrivilege("woof"); + var meowWoof = compositePrivilege("meow-woof", meow, woof); + var alt2 = alternativePrivilege("alt2", meow, woof); + + var privileges = + testPrivileges( + testPrivilegesProvider("PROVIDER", foo, bar, baz, fooBarBaz, meow, woof, meowWoof)); + + var privilegeSet = privileges.newPrivilegesSetBuilder().addPrivilege(fooBarBaz).build(); + soft.assertThat(privileges.newPrivilegesSetBuilder().addPrivileges(foo, bar, baz).build()) + .isEqualTo(privilegeSet); + soft.assertThat(privilegeSet) + .isEqualTo(privileges.newPrivilegesSetBuilder().addPrivileges(foo, bar, baz).build()); + soft.assertThat(privilegeSet.contains(fooBarBaz)).isTrue(); + soft.assertThat(privilegeSet.contains(alt1)).isTrue(); + soft.assertThat(privilegeSet.contains(foo)).isTrue(); + soft.assertThat(privilegeSet.contains(bar)).isTrue(); + soft.assertThat(privilegeSet.contains(baz)).isTrue(); + soft.assertThat(privilegeSet.contains(meowWoof)).isFalse(); + soft.assertThat(privilegeSet.contains(meow)).isFalse(); + soft.assertThat(privilegeSet.contains(woof)).isFalse(); + + var privilegeSetList = Lists.newArrayList(privilegeSet.iterator(privileges)); + soft.assertThat(privilegeSetList).containsExactlyInAnyOrder(foo, bar, baz); + soft.assertThat(privilegeSetList).doesNotContainAnyElementsOf(Set.of(woof, meow)); + soft.assertThat(privilegeSetList).doesNotContainAnyElementsOf(Set.of(meowWoof)); + + privilegeSet = privileges.newPrivilegesSetBuilder().addPrivileges(foo, baz).build(); + soft.assertThat(privilegeSet.contains(alt1)).isTrue(); + + privilegeSetList = Lists.newArrayList(privilegeSet.iterator(privileges)); + soft.assertThat(privilegeSetList).containsExactlyInAnyOrder(foo, baz); + + privilegeSet = privileges.newPrivilegesSetBuilder().addPrivileges(meowWoof).build(); + soft.assertThat(privileges.newPrivilegesSetBuilder().addPrivileges(meow, woof).build()) + .isEqualTo(privilegeSet); + soft.assertThat(privilegeSet) + .isEqualTo(privileges.newPrivilegesSetBuilder().addPrivileges(meowWoof).build()); + soft.assertThat(privilegeSet.contains(fooBarBaz)).isFalse(); + soft.assertThat(privilegeSet.contains(alt1)).isFalse(); + soft.assertThat(privilegeSet.contains(foo)).isFalse(); + soft.assertThat(privilegeSet.contains(bar)).isFalse(); + soft.assertThat(privilegeSet.contains(baz)).isFalse(); + soft.assertThat(privilegeSet.contains(meowWoof)).isTrue(); + soft.assertThat(privilegeSet.contains(meow)).isTrue(); + soft.assertThat(privilegeSet.contains(woof)).isTrue(); + soft.assertThat(privilegeSet.contains(alt2)).isTrue(); + + privilegeSetList = Lists.newArrayList(privilegeSet.iterator(privileges)); + soft.assertThat(privilegeSetList).containsExactlyInAnyOrder(woof, meow); + soft.assertThat(privilegeSetList).doesNotContainAnyElementsOf(Set.of(foo, bar, baz)); + } +} diff --git a/persistence/nosql/authz/spi/build.gradle.kts b/persistence/nosql/authz/spi/build.gradle.kts new file mode 100644 index 0000000000..616ef82729 --- /dev/null +++ b/persistence/nosql/authz/spi/build.gradle.kts @@ -0,0 +1,43 @@ +/* + * 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris AuthZ SPI" + +dependencies { + implementation(project(":polaris-persistence-nosql-authz-api")) + api(project(":polaris-version")) + + implementation(libs.guava) + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-databind") + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) +} diff --git a/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegeDefinition.java b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegeDefinition.java new file mode 100644 index 0000000000..ee38b96eaa --- /dev/null +++ b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegeDefinition.java @@ -0,0 +1,35 @@ +/* + * 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.polaris.persistence.nosql.authz.spi; + +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; + +/** + * Wrapper holding one provided privilege, intended to be potentially extended with, for example, a + * human-readable description. + */ +@PolarisImmutable +public interface PrivilegeDefinition { + Privilege privilege(); + + static ImmutablePrivilegeDefinition.Builder builder() { + return ImmutablePrivilegeDefinition.builder(); + } +} diff --git a/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesMapping.java b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesMapping.java new file mode 100644 index 0000000000..178f48eed9 --- /dev/null +++ b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesMapping.java @@ -0,0 +1,42 @@ +/* + * 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.polaris.persistence.nosql.authz.spi; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.util.Map; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; + +/** + * Value type holding the Polaris system-wide mapping of {@linkplain Privilege privilege} + * {@linkplain Privilege#name() names} to and from integer IDs. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutablePrivilegesMapping.class) +@JsonDeserialize(as = ImmutablePrivilegesMapping.class) +public interface PrivilegesMapping { + Map nameToId(); + + static ImmutablePrivilegesMapping.Builder builder() { + return ImmutablePrivilegesMapping.builder(); + } + + PrivilegesMapping EMPTY = PrivilegesMapping.builder().build(); +} diff --git a/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesProvider.java b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesProvider.java new file mode 100644 index 0000000000..4614402bd6 --- /dev/null +++ b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesProvider.java @@ -0,0 +1,33 @@ +/* + * 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.polaris.persistence.nosql.authz.spi; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.stream.Stream; + +/** + * Implementations implemented as {@link ApplicationScoped @ApplicationScoped} beans, define the + * privileges that are available to Polaris. + */ +public interface PrivilegesProvider { + /** Human-readable name. */ + String name(); + + Stream privilegeDefinitions(); +} diff --git a/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesRepository.java b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesRepository.java new file mode 100644 index 0000000000..de44c3790e --- /dev/null +++ b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesRepository.java @@ -0,0 +1,37 @@ +/* + * 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.polaris.persistence.nosql.authz.spi; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; + +/** + * API to maintain the Polaris system-wide mapping of {@linkplain Privilege privilege} {@linkplain + * Privilege#name() names} to and from integer IDs. + * + *

Implementation is provided as an {@link ApplicationScoped @ApplicationScoped} bean. + */ +public interface PrivilegesRepository { + @Nonnull + PrivilegesMapping fetchPrivilegesMapping(); + + boolean updatePrivilegesMapping( + @Nonnull PrivilegesMapping expectedState, @Nonnull PrivilegesMapping newState); +} diff --git a/persistence/nosql/authz/store-nosql/build.gradle.kts b/persistence/nosql/authz/store-nosql/build.gradle.kts new file mode 100644 index 0000000000..59077c911a --- /dev/null +++ b/persistence/nosql/authz/store-nosql/build.gradle.kts @@ -0,0 +1,62 @@ +/* + * 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris AuthZ NoSQL persistence" + +dependencies { + implementation(project(":polaris-persistence-nosql-authz-api")) + implementation(project(":polaris-persistence-nosql-authz-spi")) + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-persistence-nosql-maintenance-api")) + implementation(project(":polaris-persistence-nosql-maintenance-spi")) + + implementation(libs.guava) + implementation(libs.slf4j.api) + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-databind") + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + testFixturesApi(libs.weld.se.core) + testFixturesApi(libs.weld.junit5) + testRuntimeOnly(libs.smallrye.jandex) + + testFixturesRuntimeOnly(project(":polaris-persistence-nosql-cdi-weld")) + testFixturesApi(testFixtures(project(":polaris-persistence-nosql-cdi-weld"))) + + testImplementation(testFixtures(project(":polaris-persistence-nosql-inmemory"))) + testImplementation(libs.threeten.extra) + + testCompileOnly(libs.jakarta.annotation.api) + testCompileOnly(libs.jakarta.validation.api) + testCompileOnly(libs.jakarta.inject.api) + testCompileOnly(libs.jakarta.enterprise.cdi.api) +} diff --git a/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesMappingObj.java b/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesMappingObj.java new file mode 100644 index 0000000000..ac5841b6d0 --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesMappingObj.java @@ -0,0 +1,54 @@ +/* + * 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.polaris.persistence.nosql.authz.store.nosql; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.Obj; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesMapping; + +@PolarisImmutable +@JsonSerialize(as = ImmutablePrivilegesMappingObj.class) +@JsonDeserialize(as = ImmutablePrivilegesMappingObj.class) +public interface PrivilegesMappingObj extends Obj { + + String PRIVILEGES_MAPPING_REF_NAME = "privileges-mapping"; + + ObjType TYPE = new PrivilegesMappingObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + PrivilegesMapping privilegesMapping(); + + static ImmutablePrivilegesMappingObj.Builder builder() { + return ImmutablePrivilegesMappingObj.builder(); + } + + final class PrivilegesMappingObjType extends AbstractObjType { + public PrivilegesMappingObjType() { + super("privileges-mapping", "Privileges Mapping", PrivilegesMappingObj.class); + } + } +} diff --git a/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRepositoryImpl.java b/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRepositoryImpl.java new file mode 100644 index 0000000000..b50657690a --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRepositoryImpl.java @@ -0,0 +1,97 @@ +/* + * 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.polaris.persistence.nosql.authz.store.nosql; + +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.apache.polaris.persistence.nosql.authz.store.nosql.PrivilegesMappingObj.PRIVILEGES_MAPPING_REF_NAME; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Optional; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.SystemPersistence; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesMapping; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesRepository; + +@ApplicationScoped +class PrivilegesRepositoryImpl implements PrivilegesRepository { + private final Persistence persistence; + private ObjRef privilegesMappingObjRef; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + PrivilegesRepositoryImpl(@SystemPersistence Persistence persistence) { + this.persistence = persistence; + } + + @PostConstruct + void init() { + privilegesMappingObjRef = + persistence + .fetchOrCreateReference( + PRIVILEGES_MAPPING_REF_NAME, + () -> Optional.of(objRef(PrivilegesMappingObj.TYPE, persistence.generateId()))) + .pointer() + .orElseThrow(); + } + + @Override + public boolean updatePrivilegesMapping( + @Nonnull PrivilegesMapping expectedState, @Nonnull PrivilegesMapping newState) { + var existing = + Optional.ofNullable(persistence.fetch(privilegesMappingObjRef, PrivilegesMappingObj.class)); + + if (!existing + .map(PrivilegesMappingObj::privilegesMapping) + .orElse(PrivilegesMapping.EMPTY) + .equals(expectedState)) { + return false; + } + if (expectedState.equals(newState)) { + return true; + } + + var newObj = + PrivilegesMappingObj.builder() + .id(privilegesMappingObjRef.id()) + .versionToken("" + persistence.generateId()) + .privilegesMapping(newState) + .build(); + + return existing + .map( + privilegesMappingObj -> + persistence.conditionalUpdate( + privilegesMappingObj, newObj, PrivilegesMappingObj.class) + != null) + .orElseGet(() -> persistence.conditionalInsert(newObj, PrivilegesMappingObj.class) != null); + } + + @Override + @Nonnull + public PrivilegesMapping fetchPrivilegesMapping() { + return Optional.ofNullable( + persistence.fetch(privilegesMappingObjRef, PrivilegesMappingObj.class)) + .map(PrivilegesMappingObj::privilegesMapping) + .orElse(PrivilegesMapping.EMPTY); + } +} diff --git a/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRetainedIdentifier.java b/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRetainedIdentifier.java new file mode 100644 index 0000000000..1290172d1c --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRetainedIdentifier.java @@ -0,0 +1,54 @@ +/* + * 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.polaris.persistence.nosql.authz.store.nosql; + +import static org.apache.polaris.persistence.nosql.authz.store.nosql.PrivilegesMappingObj.PRIVILEGES_MAPPING_REF_NAME; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import org.apache.polaris.persistence.nosql.api.exceptions.ReferenceNotFoundException; +import org.apache.polaris.persistence.nosql.maintenance.spi.PerRealmRetainedIdentifier; +import org.apache.polaris.persistence.nosql.maintenance.spi.RetainedCollector; + +@ApplicationScoped +class PrivilegesRetainedIdentifier implements PerRealmRetainedIdentifier { + + @Override + public String name() { + return "Privileges Mapping"; + } + + @Override + public boolean identifyRetained(@Nonnull RetainedCollector collector) { + if (!collector.isSystemRealm()) { + return false; + } + + try { + // This retains both the reference _and_ the referenced object. + collector + .realmPersistence() + .fetchReferenceHead(PRIVILEGES_MAPPING_REF_NAME, PrivilegesMappingObj.class); + } catch (ReferenceNotFoundException ignored) { + } + + // Intentionally return false, let the maintenance service's identifier decide + return false; + } +} diff --git a/persistence/nosql/authz/store-nosql/src/main/resources/META-INF/beans.xml b/persistence/nosql/authz/store-nosql/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/authz/store-nosql/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType b/persistence/nosql/authz/store-nosql/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType new file mode 100644 index 0000000000..b5fb88869f --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType @@ -0,0 +1,20 @@ +# +# 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. +# + +org.apache.polaris.persistence.nosql.authz.store.nosql.PrivilegesMappingObj$PrivilegesMappingObjType diff --git a/persistence/nosql/authz/store-nosql/src/test/java/org/apache/polaris/persistence/nosql/authz/store/nosql/TestPrivilegesRepositoryImpl.java b/persistence/nosql/authz/store-nosql/src/test/java/org/apache/polaris/persistence/nosql/authz/store/nosql/TestPrivilegesRepositoryImpl.java new file mode 100644 index 0000000000..4b682d78ca --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/test/java/org/apache/polaris/persistence/nosql/authz/store/nosql/TestPrivilegesRepositoryImpl.java @@ -0,0 +1,62 @@ +/* + * 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.polaris.persistence.nosql.authz.store.nosql; + +import static org.assertj.core.api.InstanceOfAssertFactories.map; + +import jakarta.inject.Inject; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesMapping; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesRepository; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.jboss.weld.junit5.EnableWeld; +import org.jboss.weld.junit5.WeldInitiator; +import org.jboss.weld.junit5.WeldSetup; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +@EnableWeld +public class TestPrivilegesRepositoryImpl { + @InjectSoftAssertions SoftAssertions soft; + @WeldSetup WeldInitiator weld = WeldInitiator.performDefaultDiscovery(); + + @Inject PrivilegesRepository repository; + + @Test + public void privilegesRepository() { + soft.assertThat(repository.fetchPrivilegesMapping()) + .extracting(PrivilegesMapping::nameToId, map(String.class, Integer.class)) + .isEmpty(); + + soft.assertThat( + repository.updatePrivilegesMapping(PrivilegesMapping.EMPTY, PrivilegesMapping.EMPTY)) + .isTrue(); + var initial = PrivilegesMapping.builder().putNameToId("one", 1).putNameToId("two", 2).build(); + soft.assertThat(repository.updatePrivilegesMapping(initial, PrivilegesMapping.EMPTY)).isFalse(); + soft.assertThat(repository.updatePrivilegesMapping(PrivilegesMapping.EMPTY, initial)).isTrue(); + soft.assertThat(repository.updatePrivilegesMapping(PrivilegesMapping.EMPTY, initial)).isFalse(); + + var update = PrivilegesMapping.builder().from(initial).putNameToId("three", 3).build(); + soft.assertThat(repository.updatePrivilegesMapping(update, initial)).isFalse(); + soft.assertThat(repository.updatePrivilegesMapping(initial, update)).isTrue(); + soft.assertThat(repository.updatePrivilegesMapping(update, update)).isTrue(); + } +} diff --git a/persistence/nosql/authz/store-nosql/src/test/resources/logback-test.xml b/persistence/nosql/authz/store-nosql/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..4a4d9a629d --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/test/resources/logback-test.xml @@ -0,0 +1,32 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/persistence/nosql/authz/store-nosql/src/test/resources/weld.properties b/persistence/nosql/authz/store-nosql/src/test/resources/weld.properties new file mode 100644 index 0000000000..c26169e0e1 --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/test/resources/weld.properties @@ -0,0 +1,21 @@ +# +# 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. +# + +# See https://bugs.openjdk.org/browse/JDK-8349545 +org.jboss.weld.bootstrap.concurrentDeployment=false diff --git a/persistence/nosql/nodes/store-nosql/build.gradle.kts b/persistence/nosql/nodes/store-nosql/build.gradle.kts new file mode 100644 index 0000000000..f9513f53bd --- /dev/null +++ b/persistence/nosql/nodes/store-nosql/build.gradle.kts @@ -0,0 +1,69 @@ +/* + * 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris nodes NoSQL persistence" + +dependencies { + implementation(project(":polaris-nodes-api")) + implementation(project(":polaris-nodes-spi")) + implementation(project(":polaris-idgen-api")) + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-persistence-nosql-maintenance-spi")) + + implementation(libs.guava) + implementation(libs.slf4j.api) + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-annotations") + implementation("com.fasterxml.jackson.core:jackson-core") + implementation("com.fasterxml.jackson.core:jackson-databind") + runtimeOnly("com.fasterxml.jackson.datatype:jackson-datatype-guava") + runtimeOnly("com.fasterxml.jackson.datatype:jackson-datatype-jdk8") + runtimeOnly("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + testFixturesRuntimeOnly(project(":polaris-persistence-nosql-cdi-weld")) + testFixturesApi(testFixtures(project(":polaris-persistence-nosql-cdi-weld"))) + + testFixturesApi(libs.weld.se.core) + testFixturesApi(libs.weld.junit5) + testFixturesRuntimeOnly(libs.smallrye.jandex) + + testImplementation(project(":polaris-idgen-impl")) + testImplementation(testFixtures(project(":polaris-persistence-nosql-inmemory"))) + testImplementation(testFixtures(project(":polaris-nodes-impl"))) + testImplementation(libs.threeten.extra) + + testCompileOnly(libs.jakarta.annotation.api) + testCompileOnly(libs.jakarta.validation.api) + testCompileOnly(libs.jakarta.inject.api) + testCompileOnly(libs.jakarta.enterprise.cdi.api) +} diff --git a/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeManagementObj.java b/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeManagementObj.java new file mode 100644 index 0000000000..9a52d7e701 --- /dev/null +++ b/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeManagementObj.java @@ -0,0 +1,58 @@ +/* + * 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.polaris.persistence.nosql.nodeids.store; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import jakarta.annotation.Nullable; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.Obj; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.nodeids.spi.NodeManagementState; + +@PolarisImmutable +@JsonSerialize(as = ImmutableNodeManagementObj.class) +@JsonDeserialize(as = ImmutableNodeManagementObj.class) +public interface NodeManagementObj extends Obj, NodeManagementState { + ObjType TYPE = new NodeManagementObjType(); + long CONSTANT_ID = Long.MAX_VALUE; + + @Nullable + @Override + default String versionToken() { + return "immutable"; + } + + @Override + default long id() { + return CONSTANT_ID; // constant + } + + @Override + default ObjType type() { + return TYPE; + } + + final class NodeManagementObjType extends AbstractObjType { + public NodeManagementObjType() { + super("nodes", "Nodes", NodeManagementObj.class); + } + } +} diff --git a/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeManagementRetainedIdentifier.java b/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeManagementRetainedIdentifier.java new file mode 100644 index 0000000000..55f9df65cc --- /dev/null +++ b/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeManagementRetainedIdentifier.java @@ -0,0 +1,58 @@ +/* + * 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.polaris.persistence.nosql.nodeids.store; + +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.stream.IntStream; +import org.apache.polaris.persistence.nosql.maintenance.spi.PerRealmRetainedIdentifier; +import org.apache.polaris.persistence.nosql.maintenance.spi.RetainedCollector; +import org.apache.polaris.persistence.nosql.nodeids.api.NodeManagement; + +@ApplicationScoped +class NodeManagementRetainedIdentifier implements PerRealmRetainedIdentifier { + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + NodeManagement nodeManagement; + + @Override + public String name() { + return "Nodes"; + } + + @Override + public boolean identifyRetained(@Nonnull RetainedCollector collector) { + if (!collector.isSystemRealm()) { + return false; + } + + IntStream.range(0, nodeManagement.maxNumberOfNodes()) + .mapToLong(nodeId -> nodeManagement.systemIdForNode(nodeId)) + .mapToObj(NodeStoreImpl::constructObjId) + .forEach(collector::retainObject); + + collector.retainObject(objRef(NodeManagementObj.TYPE, NodeManagementObj.CONSTANT_ID)); + + // Intentionally return false, let the maintenance service's identifier decide + return false; + } +} diff --git a/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeObj.java b/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeObj.java new file mode 100644 index 0000000000..f4b4371e00 --- /dev/null +++ b/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeObj.java @@ -0,0 +1,49 @@ +/* + * 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.polaris.persistence.nosql.nodeids.store; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.time.Instant; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.Obj; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; + +@PolarisImmutable +@JsonSerialize(as = ImmutableNodeObj.class) +@JsonDeserialize(as = ImmutableNodeObj.class) +public interface NodeObj extends Obj { + ObjType TYPE = new NodeObjType(); + + Instant leaseTimestamp(); + + Instant expirationTimestamp(); + + @Override + default ObjType type() { + return TYPE; + } + + final class NodeObjType extends AbstractObjType { + public NodeObjType() { + super("node", "Node", NodeObj.class); + } + } +} diff --git a/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeStoreFactoryImpl.java b/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeStoreFactoryImpl.java new file mode 100644 index 0000000000..4cf06eefc8 --- /dev/null +++ b/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeStoreFactoryImpl.java @@ -0,0 +1,70 @@ +/* + * 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.polaris.persistence.nosql.nodeids.store; + +import static com.google.common.base.Preconditions.checkArgument; +import static org.apache.polaris.persistence.nosql.api.Realms.SYSTEM_REALM_ID; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Optional; +import org.apache.polaris.ids.api.IdGenerator; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.StartupPersistence; +import org.apache.polaris.persistence.nosql.nodeids.spi.NodeManagementState; +import org.apache.polaris.persistence.nosql.nodeids.spi.NodeStore; +import org.apache.polaris.persistence.nosql.nodeids.spi.NodeStoreFactory; + +@ApplicationScoped +class NodeStoreFactoryImpl implements NodeStoreFactory { + private final Persistence startupPersistence; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + NodeStoreFactoryImpl(@StartupPersistence Persistence startupPersistence) { + checkArgument( + SYSTEM_REALM_ID.equals(startupPersistence.realmId()), + "Realms management must happen in the %s realm", + SYSTEM_REALM_ID); + this.startupPersistence = startupPersistence; + } + + @Override + @Nonnull + public NodeStore createNodeStore(@Nonnull IdGenerator idGenerator) { + return new NodeStoreImpl(startupPersistence, idGenerator); + } + + @Override + public Optional fetchManagementState() { + return Optional.ofNullable( + startupPersistence.fetch( + objRef(NodeManagementObj.TYPE, NodeManagementObj.CONSTANT_ID, 1), + NodeManagementObj.class)); + } + + @Override + public boolean storeManagementState(@Nonnull NodeManagementState state) { + return startupPersistence.conditionalInsert( + ImmutableNodeManagementObj.builder().from(state).build(), NodeManagementObj.class) + != null; + } +} diff --git a/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeStoreImpl.java b/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeStoreImpl.java new file mode 100644 index 0000000000..1c38f11005 --- /dev/null +++ b/persistence/nosql/nodes/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/nodeids/store/NodeStoreImpl.java @@ -0,0 +1,116 @@ +/* + * 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.polaris.persistence.nosql.nodeids.store; + +import static com.google.common.base.Preconditions.checkArgument; +import static org.apache.polaris.persistence.nosql.api.Realms.SYSTEM_REALM_ID; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.util.Optional; +import java.util.UUID; +import org.apache.polaris.ids.api.IdGenerator; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.nodeids.spi.ImmutableNodeState; +import org.apache.polaris.persistence.nosql.nodeids.spi.NodeState; +import org.apache.polaris.persistence.nosql.nodeids.spi.NodeStore; + +record NodeStoreImpl(Persistence startupPersistence, IdGenerator idGenerator) implements NodeStore { + NodeStoreImpl { + checkArgument( + SYSTEM_REALM_ID.equals(startupPersistence.realmId()), + "Realms management must happen in the %s realm", + SYSTEM_REALM_ID); + } + + @Override + @Nullable + public NodeState persist( + int nodeId, Optional expectedNodeState, @Nonnull NodeState newState) { + checkArgument(nodeId >= 0, "Illegal node ID %s", nodeId); + + var persistenceId = idGenerator.systemIdForNode(nodeId); + var newObj = + ImmutableNodeObj.builder() + .leaseTimestamp(newState.leaseTimestamp()) + .expirationTimestamp(newState.expirationTimestamp()) + .id(persistenceId) + .versionToken(UUID.randomUUID().toString()) + .build(); + + var existing = startupPersistence.fetch(constructObjId(persistenceId), NodeObj.class); + if (expectedNodeState.isEmpty()) { + return existing == null + ? asNodeState(startupPersistence.conditionalInsert(newObj, NodeObj.class)) + : null; + } else { + if (existing == null) { + return null; + } + var expected = expectedNodeState.get(); + var real = asNodeState(existing); + if (!expected.equals(real)) { + return null; + } + return asNodeState(startupPersistence.conditionalUpdate(existing, newObj, NodeObj.class)); + } + } + + @Override + @Nonnull + public NodeState[] fetchMany(@Nonnull int... nodeIds) { + var objIds = new ObjRef[nodeIds.length]; + for (int i = 0; i < nodeIds.length; i++) { + var nodeId = nodeIds[i]; + checkArgument(nodeId >= 0, "Illegal node ID %s", nodeId); + objIds[i] = objIdForNode(nodeId); + } + var fetched = startupPersistence.fetchMany(NodeObj.class, objIds); + var result = new NodeState[nodeIds.length]; + for (int i = 0; i < nodeIds.length; i++) { + result[i] = asNodeState(fetched[i]); + } + return result; + } + + @Override + public Optional fetch(int nodeId) { + var objId = objIdForNode(nodeId); + return Optional.ofNullable(asNodeState(startupPersistence.fetch(objId, NodeObj.class))); + } + + private static NodeState asNodeState(NodeObj result) { + return result != null + ? ImmutableNodeState.builder() + .leaseTimestamp(result.leaseTimestamp()) + .expirationTimestamp(result.expirationTimestamp()) + .build() + : null; + } + + ObjRef objIdForNode(int nodeId) { + return constructObjId(idGenerator.systemIdForNode(nodeId)); + } + + static ObjRef constructObjId(long persistenceId) { + return objRef(NodeObj.TYPE.id(), persistenceId, 1); + } +} diff --git a/persistence/nosql/nodes/store-nosql/src/main/resources/META-INF/beans.xml b/persistence/nosql/nodes/store-nosql/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/nodes/store-nosql/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/nodes/store-nosql/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType b/persistence/nosql/nodes/store-nosql/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType new file mode 100644 index 0000000000..40fad0a882 --- /dev/null +++ b/persistence/nosql/nodes/store-nosql/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType @@ -0,0 +1,21 @@ +# +# 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. +# + +org.apache.polaris.persistence.nosql.nodeids.store.NodeObj$NodeObjType +org.apache.polaris.persistence.nosql.nodeids.store.NodeManagementObj$NodeManagementObjType diff --git a/persistence/nosql/nodes/store-nosql/src/test/java/org/apache/polaris/persistence/nosql/nodeids/store/TestNodeStoreIntegration.java b/persistence/nosql/nodes/store-nosql/src/test/java/org/apache/polaris/persistence/nosql/nodeids/store/TestNodeStoreIntegration.java new file mode 100644 index 0000000000..08f4d103df --- /dev/null +++ b/persistence/nosql/nodes/store-nosql/src/test/java/org/apache/polaris/persistence/nosql/nodeids/store/TestNodeStoreIntegration.java @@ -0,0 +1,178 @@ +/* + * 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.polaris.persistence.nosql.nodeids.store; + +import static java.util.Objects.requireNonNull; +import static org.apache.polaris.persistence.nosql.nodeids.impl.Util.idgenSpecFromManagementState; + +import jakarta.inject.Inject; +import java.util.ArrayList; +import org.apache.polaris.ids.api.IdGeneratorSpec; +import org.apache.polaris.ids.api.ImmutableIdGeneratorSpec; +import org.apache.polaris.ids.api.MonotonicClock; +import org.apache.polaris.persistence.nosql.nodeids.api.NodeLease; +import org.apache.polaris.persistence.nosql.nodeids.api.NodeManagement; +import org.apache.polaris.persistence.nosql.nodeids.spi.ImmutableBuildableNodeManagementState; +import org.apache.polaris.persistence.nosql.nodeids.spi.NodeManagementState; +import org.apache.polaris.persistence.nosql.nodeids.spi.NodeStoreFactory; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.jboss.weld.junit5.EnableWeld; +import org.jboss.weld.junit5.WeldInitiator; +import org.jboss.weld.junit5.WeldSetup; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@SuppressWarnings("CdiInjectionPointsInspection") +@ExtendWith(SoftAssertionsExtension.class) +@EnableWeld +public class TestNodeStoreIntegration { + @InjectSoftAssertions protected SoftAssertions soft; + @WeldSetup WeldInitiator weld = WeldInitiator.performDefaultDiscovery(); + + @Inject NodeStoreFactory nodeStoreFactory; + @Inject NodeManagement nodeManagement; + @Inject MonotonicClock clock; + + @Test + public void managementState() { + soft.assertThat(nodeStoreFactory.fetchManagementState()).isEmpty(); + + var buildableIdgenSpec = IdGeneratorSpec.BuildableIdGeneratorSpec.builder().build(); + var idgenSpec = ImmutableIdGeneratorSpec.builder().from(buildableIdgenSpec).build(); + var nodeManagementSpec = + ImmutableBuildableNodeManagementState.builder().idGeneratorSpec(idgenSpec).build(); + + soft.assertThat(nodeStoreFactory.storeManagementState(nodeManagementSpec)).isTrue(); + var fetched = nodeStoreFactory.fetchManagementState(); + soft.assertThat(fetched).isPresent(); + soft.assertThat(fetched) + .get() + .extracting( + NodeManagementState::idGeneratorSpec, + InstanceOfAssertFactories.optional(IdGeneratorSpec.class)) + .get() + .isEqualTo(idgenSpec); + var specFromFetched = idgenSpecFromManagementState(fetched); + soft.assertThat(specFromFetched).isEqualTo(idgenSpec); + soft.assertThat(nodeStoreFactory.storeManagementState(nodeManagementSpec)).isFalse(); + } + + @Test + public void simple() { + var lease = nodeManagement.lease(); + soft.assertThat(lease).isNotNull(); + soft.assertThat(lease.nodeIdIfValid()).isNotEqualTo(-1); + } + + @Test + public void allocateAll() { + var numNodeIds = nodeManagement.maxNumberOfNodes(); + var leases = new ArrayList(); + for (int i = 0; i < numNodeIds; i++) { + soft.assertThatCode(() -> leases.add(nodeManagement.lease())) + .describedAs("n = %d", i) + .doesNotThrowAnyException(); + } + soft.assertThatIllegalStateException() + .isThrownBy(nodeManagement::lease) + .withMessage("Could not lease any node ID"); + + soft.assertThat(leases).hasSize(numNodeIds); + + for (var lease : leases) { + soft.assertThat(lease.nodeIdIfValid()).isNotEqualTo(-1); + soft.assertThat( + requireNonNull(lease.node()) + .valid(requireNonNull(lease.node()).expirationTimestamp().toEpochMilli())) + .isFalse(); + } + + clock.waitUntilTimeMillisAdvanced(); + + // Renew all leases + + for (var lease : leases) { + var nodeId = lease.nodeIdIfValid(); + var beforeRelease = nodeManagement.getNodeInfo(nodeId).orElseThrow(); + soft.assertThat(beforeRelease).isEqualTo(lease.node()); + var n = requireNonNull(lease.node()); + var beforeExpire = n.expirationTimestamp(); + var beforeRenew = n.renewLeaseTimestamp(); + var beforeLease = n.leaseTimestamp(); + + lease.renew(); + var fetched = nodeManagement.getNodeInfo(nodeId).orElseThrow(); + soft.assertThat(fetched).isEqualTo(lease.node()); + + n = requireNonNull(lease.node()); + soft.assertThat(n.expirationTimestamp()).isAfter(beforeExpire); + soft.assertThat(n.renewLeaseTimestamp()).isAfter(beforeRenew); + soft.assertThat(n.leaseTimestamp()).isEqualTo(beforeLease); + + soft.assertAll(); + } + + // Release all leases + + for (var lease : leases) { + var nodeId = lease.nodeIdIfValid(); + var beforeRelease = nodeManagement.getNodeInfo(nodeId).orElseThrow(); + var n = requireNonNull(lease.node()); + soft.assertThat(beforeRelease).isEqualTo(n); + var beforeExpire = n.expirationTimestamp(); + var beforeLease = n.leaseTimestamp(); + + lease.release(); + soft.assertThat(lease.nodeIdIfValid()).isEqualTo(-1); + + var fetched = nodeManagement.getNodeInfo(nodeId).orElseThrow(); + soft.assertThat(fetched.expirationTimestamp()) + .isBeforeOrEqualTo(clock.currentInstant()) + .isNotEqualTo(beforeExpire); + soft.assertThat(fetched.leaseTimestamp()).isEqualTo(beforeLease); + + soft.assertThat(fetched.valid(clock.currentTimeMillis())).isFalse(); + nodeManagement.getNodeInfo(nodeId); + + soft.assertAll(); + } + + leases.clear(); + + // Repeat allocation of all nodes + + clock.waitUntilTimeMillisAdvanced(); + + for (int i = 0; i < numNodeIds; i++) { + soft.assertThatCode(() -> leases.add(nodeManagement.lease())) + .describedAs("n = %d", i) + .doesNotThrowAnyException(); + + soft.assertAll(); + } + soft.assertThatIllegalStateException() + .isThrownBy(nodeManagement::lease) + .withMessage("Could not lease any node ID"); + + soft.assertThat(leases).hasSize(numNodeIds); + } +} diff --git a/persistence/nosql/nodes/store-nosql/src/test/resources/logback-test.xml b/persistence/nosql/nodes/store-nosql/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..aafa701dc4 --- /dev/null +++ b/persistence/nosql/nodes/store-nosql/src/test/resources/logback-test.xml @@ -0,0 +1,30 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/persistence/nosql/nodes/store-nosql/src/test/resources/weld.properties b/persistence/nosql/nodes/store-nosql/src/test/resources/weld.properties new file mode 100644 index 0000000000..c26169e0e1 --- /dev/null +++ b/persistence/nosql/nodes/store-nosql/src/test/resources/weld.properties @@ -0,0 +1,21 @@ +# +# 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. +# + +# See https://bugs.openjdk.org/browse/JDK-8349545 +org.jboss.weld.bootstrap.concurrentDeployment=false diff --git a/persistence/nosql/persistence/api/src/main/java/org/apache/polaris/persistence/nosql/api/Realms.java b/persistence/nosql/persistence/api/src/main/java/org/apache/polaris/persistence/nosql/api/Realms.java index 6275875871..4846b9b978 100644 --- a/persistence/nosql/persistence/api/src/main/java/org/apache/polaris/persistence/nosql/api/Realms.java +++ b/persistence/nosql/persistence/api/src/main/java/org/apache/polaris/persistence/nosql/api/Realms.java @@ -23,8 +23,8 @@ private Realms() {} /** * Realms with special meanings and "non-standard behavior" (as per {@code - * org.apache.polaris.realms.api.RealmDefinition.RealmStatus}) have to have an ID that starts with - * this prefix. + * org.apache.polaris.persistence.nosql.realms.api.RealmDefinition.RealmStatus}) have to have an + * ID that starts with this prefix. */ public static final String SYSTEM_REALM_PREFIX = "::"; diff --git a/persistence/nosql/persistence/cdi/README.md b/persistence/nosql/persistence/cdi/README.md new file mode 100644 index 0000000000..8f9dc55452 --- /dev/null +++ b/persistence/nosql/persistence/cdi/README.md @@ -0,0 +1,41 @@ + + +# CDI functionality for Polaris NoSQL persistence + +NoSQL persistence provides three modules for CDI: +* A module for Quarkus, which Polaris used for production deployments. +* A module for Weld, which is used for testing purposes. +* A module with shared CDI functionality for both Quarkus and Weld. + +Polaris runs on top of the Quarkus framework, leveraging CDI. + +To build and run tests in a more performant way, many test classes in Polaris NoSQL persistence +uses the CDI reference implementation Weld instead of Quarkus, as it requires no intermediate +augmentation (think: Quarkus build). + +The biggest difference between the Quarkus and Weld variants is the way how database specific +`Backend` instances are produced, because the Weld variant targets testing purposes. +* Weld locates the `Backend` instances using Java's service loader mechanism via + `org.apache.polaris.persistence.nosql.api.backend.BackendLoader.findFactoryByName()`, which is + also what the the NoSQL persistence JUnit test extension uses. +* In Quarkus, the `Backend` instances are located using a CDI identifier-based mechanism. + There are also backend specific builders that leverage Quarkus extensions for the respective + database backends. + The Quarkus variant also adds OpenTelemetry instrumentation to the `Backend` instances. diff --git a/persistence/nosql/persistence/cdi/common/build.gradle.kts b/persistence/nosql/persistence/cdi/common/build.gradle.kts new file mode 100644 index 0000000000..8bb09143ed --- /dev/null +++ b/persistence/nosql/persistence/cdi/common/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris NoSQL persistence, providers for CDI (not Quarkus)." + +dependencies { + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-persistence-nosql-realms-api")) + implementation(project(":polaris-async-api")) + implementation(project(":polaris-idgen-api")) + implementation(project(":polaris-nodes-api")) + runtimeOnly(project(":polaris-nodes-impl")) + runtimeOnly(project(":polaris-nodes-store-nosql")) + runtimeOnly(project(":polaris-persistence-nosql-realms-impl")) + runtimeOnly(project(":polaris-persistence-nosql-realms-store-nosql")) + + compileOnly(platform(libs.jackson.bom)) + compileOnly("com.fasterxml.jackson.core:jackson-annotations") + compileOnly("com.fasterxml.jackson.core:jackson-databind") + + implementation(libs.guava) + implementation(libs.slf4j.api) + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) +} + +tasks.withType { isFailOnError = false } diff --git a/persistence/nosql/persistence/cdi/common/src/main/java/org/apache/polaris/persistence/nosql/cdi/IdGeneratorProvider.java b/persistence/nosql/persistence/cdi/common/src/main/java/org/apache/polaris/persistence/nosql/cdi/IdGeneratorProvider.java new file mode 100644 index 0000000000..bb453bb916 --- /dev/null +++ b/persistence/nosql/persistence/cdi/common/src/main/java/org/apache/polaris/persistence/nosql/cdi/IdGeneratorProvider.java @@ -0,0 +1,42 @@ +/* + * 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.polaris.persistence.nosql.cdi; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import org.apache.polaris.ids.api.IdGenerator; +import org.apache.polaris.persistence.nosql.nodeids.api.NodeLease; +import org.apache.polaris.persistence.nosql.nodeids.api.NodeManagement; + +@ApplicationScoped +class IdGeneratorProvider { + @SuppressWarnings("CdiInjectionPointsInspection") + @Produces + @ApplicationScoped + IdGenerator idGenerator(NodeLease leasedNode, NodeManagement nodeManagement) { + return nodeManagement.buildIdGenerator(leasedNode); + } + + @SuppressWarnings("CdiInjectionPointsInspection") + @Produces + @ApplicationScoped + NodeLease leasedNode(NodeManagement nodeManagement) { + return nodeManagement.lease(); + } +} diff --git a/persistence/nosql/persistence/cdi/common/src/main/java/org/apache/polaris/persistence/nosql/cdi/persistence/PersistenceDecorators.java b/persistence/nosql/persistence/cdi/common/src/main/java/org/apache/polaris/persistence/nosql/cdi/persistence/PersistenceDecorators.java new file mode 100644 index 0000000000..913e843f82 --- /dev/null +++ b/persistence/nosql/persistence/cdi/common/src/main/java/org/apache/polaris/persistence/nosql/cdi/persistence/PersistenceDecorators.java @@ -0,0 +1,52 @@ +/* + * 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.polaris.persistence.nosql.cdi.persistence; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import java.util.Comparator; +import java.util.List; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.PersistenceDecorator; + +/** Applies {@link PersistenceDecorator}s sorted by {@link PersistenceDecorator#priority()}. */ +@ApplicationScoped +public class PersistenceDecorators { + @Inject Instance persistenceDecorators; + + private List activeDecorators; + + @PostConstruct + void init() { + this.activeDecorators = + persistenceDecorators.stream() + .filter(PersistenceDecorator::active) + .sorted(Comparator.comparingInt(PersistenceDecorator::priority)) + .toList(); + } + + public Persistence decorate(Persistence persistence) { + for (var decorator : activeDecorators) { + persistence = decorator.decorate(persistence); + } + return persistence; + } +} diff --git a/persistence/nosql/persistence/cdi/common/src/main/java/org/apache/polaris/persistence/nosql/cdi/persistence/PersistenceProducers.java b/persistence/nosql/persistence/cdi/common/src/main/java/org/apache/polaris/persistence/nosql/cdi/persistence/PersistenceProducers.java new file mode 100644 index 0000000000..0a1b328c95 --- /dev/null +++ b/persistence/nosql/persistence/cdi/common/src/main/java/org/apache/polaris/persistence/nosql/cdi/persistence/PersistenceProducers.java @@ -0,0 +1,81 @@ +/* + * 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.polaris.persistence.nosql.cdi.persistence; + +import static org.apache.polaris.persistence.nosql.api.Realms.SYSTEM_REALM_ID; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import org.apache.polaris.ids.api.IdGenerator; +import org.apache.polaris.ids.api.MonotonicClock; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.PersistenceParams; +import org.apache.polaris.persistence.nosql.api.StartupPersistence; +import org.apache.polaris.persistence.nosql.api.SystemPersistence; +import org.apache.polaris.persistence.nosql.api.backend.Backend; + +@ApplicationScoped +class PersistenceProducers { + + private final Backend backend; + private final IdGenerator idGenerator; + private final MonotonicClock monotonicClock; + private final PersistenceDecorators persistenceDecorators; + private final PersistenceParams persistenceParams; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + PersistenceProducers( + Backend backend, + IdGenerator idGenerator, + MonotonicClock monotonicClock, + PersistenceDecorators persistenceDecorators, + PersistenceParams persistenceParams) { + this.backend = backend; + this.idGenerator = idGenerator; + this.monotonicClock = monotonicClock; + this.persistenceDecorators = persistenceDecorators; + this.persistenceParams = persistenceParams; + } + + @ApplicationScoped + @Produces + @StartupPersistence + Persistence startupPersistence() { + var persistence = + backend.newPersistence( + x -> backend, + PersistenceParams.BuildablePersistenceParams.builder().build(), + SYSTEM_REALM_ID, + monotonicClock, + IdGenerator.NONE); + return persistenceDecorators.decorate(persistence); + } + + @ApplicationScoped + @Produces + @SystemPersistence + Persistence systemPersistence() { + var persistence = + backend.newPersistence( + x -> backend, persistenceParams, SYSTEM_REALM_ID, monotonicClock, idGenerator); + return persistenceDecorators.decorate(persistence); + } +} diff --git a/persistence/nosql/persistence/cdi/common/src/main/java/org/apache/polaris/persistence/nosql/cdi/persistence/RealmPersistence.java b/persistence/nosql/persistence/cdi/common/src/main/java/org/apache/polaris/persistence/nosql/cdi/persistence/RealmPersistence.java new file mode 100644 index 0000000000..9902eb8892 --- /dev/null +++ b/persistence/nosql/persistence/cdi/common/src/main/java/org/apache/polaris/persistence/nosql/cdi/persistence/RealmPersistence.java @@ -0,0 +1,89 @@ +/* + * 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.polaris.persistence.nosql.cdi.persistence; + +import static com.google.common.base.Preconditions.checkState; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.apache.polaris.ids.api.IdGenerator; +import org.apache.polaris.ids.api.MonotonicClock; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.PersistenceParams; +import org.apache.polaris.persistence.nosql.api.RealmPersistenceFactory; +import org.apache.polaris.persistence.nosql.api.backend.Backend; + +@ApplicationScoped +class RealmPersistence implements RealmPersistenceFactory { + private final PersistenceParams persistenceConfig; + private final Backend backend; + private final IdGenerator idGenerator; + private final MonotonicClock monotonicClock; + private final PersistenceDecorators persistenceDecorators; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + RealmPersistence( + PersistenceParams persistenceConfig, + Backend backend, + IdGenerator idGenerator, + MonotonicClock monotonicClock, + PersistenceDecorators persistenceDecorators) { + this.persistenceConfig = persistenceConfig; + this.backend = backend; + this.idGenerator = idGenerator; + this.monotonicClock = monotonicClock; + this.persistenceDecorators = persistenceDecorators; + } + + @Override + public RealmPersistenceBuilder newBuilder() { + return new RealmPersistenceBuilder() { + private boolean skipDecorators; + private String realmId; + private boolean consumed; + + @Override + public RealmPersistenceBuilder realmId(@Nonnull String realmId) { + checkState(this.realmId == null, "RealmPersistenceBuilder can only be used once"); + this.realmId = realmId; + return this; + } + + @Override + public RealmPersistenceBuilder skipDecorators() { + this.skipDecorators = true; + return this; + } + + @Override + public Persistence build() { + checkState(!consumed, "RealmPersistenceBuilder can only be used once"); + checkState(realmId != null, "Must call RealmPersistenceBuilder.setRealmId() before .build"); + consumed = true; + + var persistence = + backend.newPersistence( + x -> backend, persistenceConfig, realmId, monotonicClock, idGenerator); + return skipDecorators ? persistence : persistenceDecorators.decorate(persistence); + } + }; + } +} diff --git a/persistence/nosql/persistence/cdi/common/src/main/resources/META-INF/beans.xml b/persistence/nosql/persistence/cdi/common/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/persistence/cdi/common/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/build.gradle.kts b/persistence/nosql/persistence/cdi/quarkus-distcache/build.gradle.kts new file mode 100644 index 0000000000..68100338a1 --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/build.gradle.kts @@ -0,0 +1,75 @@ +/* + * 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris NoSQL persistence, distributed cache invalidation for Quarkus." + +dependencies { + implementation(project(":polaris-persistence-nosql-cdi-common")) + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-idgen-api")) + runtimeOnly(project(":polaris-nodes-impl")) + runtimeOnly(project(":polaris-nodes-store-nosql")) + runtimeOnly(project(":polaris-persistence-nosql-realms-impl")) + runtimeOnly(project(":polaris-persistence-nosql-realms-store-nosql")) + runtimeOnly(project(":polaris-async-vertx")) + runtimeOnly(project(":polaris-idgen-impl")) + runtimeOnly(project(":polaris-persistence-nosql-authz-impl")) + runtimeOnly(project(":polaris-persistence-nosql-authz-store-nosql")) + + compileOnly(platform(libs.micrometer.bom)) + compileOnly("io.micrometer:micrometer-core") + compileOnly(platform(libs.opentelemetry.instrumentation.bom.alpha)) + compileOnly("io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations") + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-annotations") + implementation("com.fasterxml.jackson.core:jackson-databind") + + compileOnly(libs.smallrye.config.core) + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + + implementation(platform(libs.quarkus.bom)) + implementation("io.quarkus:quarkus-core") + implementation("io.quarkus:quarkus-micrometer") + implementation("io.quarkus:quarkus-mongodb-client") + + implementation(libs.jakarta.ws.rs.api) + + implementation(libs.guava) + implementation(libs.slf4j.api) + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + + // Must stick with the Quarkus platform versions of Vert.X + // (signature of io.vertx.core.Vertx.createHttpClient() changed from 4.5 to 5.0) + testImplementation(enforcedPlatform(libs.quarkus.bom)) +} diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/AddressResolver.java b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/AddressResolver.java new file mode 100644 index 0000000000..d47679568e --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/AddressResolver.java @@ -0,0 +1,166 @@ +/* + * 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.polaris.persistence.nosql.quarkus.distcache; + +import static com.google.common.base.Preconditions.checkState; +import static java.net.NetworkInterface.networkInterfaces; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toUnmodifiableSet; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.dns.DnsClient; +import io.vertx.core.dns.DnsClientOptions; +import java.net.InetAddress; +import java.net.InterfaceAddress; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +final class AddressResolver { + private static final Logger LOGGER = LoggerFactory.getLogger(AddressResolver.class); + + private final DnsClient dnsClient; + private final List searchList; + + static final Set LOCAL_ADDRESSES; + + private static final boolean IP_V4_ONLY; + + static { + try { + LOCAL_ADDRESSES = + networkInterfaces() + .flatMap( + ni -> + ni.getInterfaceAddresses().stream() + // Need to do this InetAddress->byte[]->InetAddress dance to get rid of + // host-address suffixes as in `0:0:0:0:0:0:0:1%lo` + .map(InterfaceAddress::getAddress) + .map(InetAddress::getAddress) + .map( + a -> { + try { + return InetAddress.getByAddress(a); + } catch (UnknownHostException e) { + // Should never happen when calling getByAddress() with an IPv4 or + // IPv6 address + throw new RuntimeException(e); + } + }) + .map(InetAddress::getHostAddress)) + .collect(toUnmodifiableSet()); + + IP_V4_ONLY = Boolean.parseBoolean(System.getProperty("java.net.preferIPv4Stack", "false")); + } catch (SocketException e) { + throw new RuntimeException(e); + } + } + + AddressResolver(DnsClient dnsClient, List searchList) { + this.dnsClient = dnsClient; + this.searchList = searchList; + } + + /** + * Uses a "default" {@link DnsClient} using the first {@code nameserver} and the {@code search} + * list configured in {@code /etc/resolv.conf}. + */ + AddressResolver(Vertx vertx) { + this(createDnsClient(vertx), ResolvConf.system().getSearchList()); + } + + /** + * Creates a "default" {@link DnsClient} using the first nameserver configured in {@code + * /etc/resolv.conf}. + */ + static DnsClient createDnsClient(Vertx vertx) { + var nameservers = ResolvConf.system().getNameservers(); + checkState(!nameservers.isEmpty(), "No nameserver configured in /etc/resolv.conf"); + var nameserver = nameservers.getFirst(); + LOGGER.info( + "Using nameserver {}/{} with search list {}", + nameserver.getHostName(), + nameserver.getAddress().getHostAddress(), + ResolvConf.system().getSearchList()); + return vertx.createDnsClient( + new DnsClientOptions() + // 5 seconds should be enough to resolve + .setQueryTimeout(5000) + .setHost(nameserver.getAddress().getHostAddress()) + .setPort(nameserver.getPort())); + } + + DnsClient dnsClient() { + return dnsClient; + } + + private Future> resolveSingle(String name) { + var resultA = dnsClient.resolveA(name); + if (IP_V4_ONLY) { + return resultA; + } + return resultA.compose( + a -> + dnsClient + .resolveAAAA(name) + .map(aaaa -> Stream.concat(aaaa.stream(), a.stream()).collect(toList()))); + } + + Future> resolve(String name) { + if (name.startsWith("=")) { + return Future.succeededFuture(List.of(name.substring(1))); + } + + // By convention, do not consult the 'search' list, when the name to query ends with a dot. + var exact = name.endsWith("."); + var query = exact ? name.substring(0, name.length() - 1) : name; + var future = resolveSingle(query); + if (!exact) { + // Consult the 'search' list if the above 'resolveName' fails. + for (var search : searchList) { + future = future.recover(t -> resolveSingle(query + '.' + search)); + } + } + + return future; + } + + Future> resolveAll(List names) { + var composite = Future.all(names.stream().map(this::resolve).collect(toList())); + return composite.map( + c -> + IntStream.range(0, c.size()) + .mapToObj(c::resultAt) + .map( + e -> { + @SuppressWarnings("unchecked") + var casted = (List) e; + return casted.stream(); + }) + .reduce(Stream::concat) + .map(s -> s.collect(toList())) + .orElse(List.of())); + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/CacheInvalidationInfra.java b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/CacheInvalidationInfra.java new file mode 100644 index 0000000000..d353396618 --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/CacheInvalidationInfra.java @@ -0,0 +1,33 @@ +/* + * 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.polaris.persistence.nosql.quarkus.distcache; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import java.util.UUID; + +@ApplicationScoped +class CacheInvalidationInfra { + + @Produces + @ApplicationScoped + ServerInstanceId ephemeralServerInstanceId() { + return ServerInstanceId.of(UUID.randomUUID().toString()); + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/CacheInvalidationReceiver.java b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/CacheInvalidationReceiver.java new file mode 100644 index 0000000000..9d6e3ad1ec --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/CacheInvalidationReceiver.java @@ -0,0 +1,164 @@ +/* + * 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.polaris.persistence.nosql.quarkus.distcache; + +import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; +import static java.util.Collections.emptyList; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.vertx.http.ManagementInterface; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations; +import org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.CacheInvalidationEvictObj; +import org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.CacheInvalidationEvictReference; +import org.apache.polaris.persistence.nosql.api.cache.DistributedCacheInvalidation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +// See https://quarkus.io/guides/management-interface-reference#management-endpoint-application +@ApplicationScoped +class CacheInvalidationReceiver { + static final String CACHE_INVALIDATION_TOKEN_HEADER = "Polaris-Cache-Invalidation-Token"; + + private static final Logger LOGGER = LoggerFactory.getLogger(CacheInvalidationReceiver.class); + + private final DistributedCacheInvalidation distributedCacheInvalidation; + private final String serverInstanceId; + private final Set validTokens; + private final String invalidationPath; + private final ObjectMapper objectMapper; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + CacheInvalidationReceiver( + QuarkusDistributedCacheInvalidationsConfig storeConfig, + ServerInstanceId serverInstanceId, + DistributedCacheInvalidation.Receiver distributedCacheInvalidation) { + this.distributedCacheInvalidation = distributedCacheInvalidation; + this.serverInstanceId = serverInstanceId.instanceId(); + this.invalidationPath = storeConfig.cacheInvalidationUri(); + this.validTokens = + new HashSet<>(storeConfig.cacheInvalidationValidTokens().orElse(emptyList())); + this.objectMapper = + new ObjectMapper() + // forward compatibility + .disable(FAIL_ON_UNKNOWN_PROPERTIES); + } + + void registerManagementRoutes(@Observes ManagementInterface mi) { + mi.router().post(invalidationPath).handler(this::cacheInvalidations); + } + + void cacheInvalidations(RoutingContext rc) { + var request = rc.request(); + var senderId = request.getParam("sender"); + var token = request.getHeader(CACHE_INVALIDATION_TOKEN_HEADER); + + cacheInvalidations( + rc, + () -> { + try { + var json = rc.body().asString(); + if (json == null || json.isEmpty()) { + return CacheInvalidations.cacheInvalidations(emptyList()); + } + return objectMapper.readValue(json, CacheInvalidations.class); + } catch (Exception e) { + LOGGER.error("Failed to deserialize cache invalidation", e); + return CacheInvalidations.cacheInvalidations(emptyList()); + } + }, + senderId, + token); + } + + void cacheInvalidations( + RoutingContext rc, + Supplier invalidations, + String senderId, + String token) { + if (token == null || !validTokens.contains(token)) { + LOGGER.warn("Received cache invalidation with invalid token {}", token); + responseInvalidToken(rc); + return; + } + if (serverInstanceId.equals(senderId)) { + LOGGER.trace("Ignoring invalidations from local instance"); + responseNoContent(rc); + return; + } + if (!"application/json".equals(rc.request().getHeader("Content-Type"))) { + LOGGER.warn("Received cache invalidation with invalid HTTP content type"); + responseInvalidContentType(rc); + return; + } + + List invalidationList; + try { + invalidationList = invalidations.get().invalidations(); + } catch (RuntimeException e) { + responseServerError(rc); + return; + } + + var cacheInvalidation = distributedCacheInvalidation; + if (cacheInvalidation != null) { + for (CacheInvalidations.CacheInvalidation invalidation : invalidationList) { + switch (invalidation.type()) { + case CacheInvalidationEvictObj.TYPE -> { + var putObj = (CacheInvalidationEvictObj) invalidation; + cacheInvalidation.evictObj(putObj.realmId(), putObj.id()); + } + case CacheInvalidationEvictReference.TYPE -> { + var putReference = (CacheInvalidationEvictReference) invalidation; + cacheInvalidation.evictReference(putReference.realmId(), putReference.ref()); + } + default -> { + // nothing we can do about a new invalidation type here + } + } + } + } + + responseNoContent(rc); + } + + private void responseServerError(RoutingContext rc) { + rc.response().setStatusCode(500).setStatusMessage("Server error parsing request body").end(); + } + + private void responseInvalidToken(RoutingContext rc) { + rc.response().setStatusCode(400).setStatusMessage("Invalid token").end(); + } + + private void responseInvalidContentType(RoutingContext rc) { + rc.response().setStatusCode(415).setStatusMessage("Unsupported media type").end(); + } + + private void responseNoContent(RoutingContext rc) { + rc.response().setStatusCode(204).setStatusMessage("No content").end(); + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/CacheInvalidationSender.java b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/CacheInvalidationSender.java new file mode 100644 index 0000000000..2ee1384805 --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/CacheInvalidationSender.java @@ -0,0 +1,311 @@ +/* + * 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.polaris.persistence.nosql.quarkus.distcache; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static java.util.Collections.emptyList; +import static org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.CacheInvalidationEvictObj.cacheInvalidationEvictObj; +import static org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.CacheInvalidationEvictReference.cacheInvalidationEvictReference; +import static org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.cacheInvalidations; +import static org.apache.polaris.persistence.nosql.quarkus.distcache.CacheInvalidationReceiver.CACHE_INVALIDATION_TOKEN_HEADER; +import static org.apache.polaris.persistence.nosql.quarkus.distcache.QuarkusDistributedCacheInvalidationsConfig.CACHE_INVALIDATIONS_CONFIG_PREFIX; +import static org.apache.polaris.persistence.nosql.quarkus.distcache.QuarkusDistributedCacheInvalidationsConfig.CONFIG_SERVICE_NAMES; +import static org.apache.polaris.persistence.nosql.quarkus.distcache.QuarkusDistributedCacheInvalidationsConfig.CONFIG_VALID_TOKENS; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; +import io.quarkus.runtime.Startup; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpClientResponse; +import io.vertx.core.http.HttpMethod; +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.CacheInvalidation; +import org.apache.polaris.persistence.nosql.api.cache.DistributedCacheInvalidation; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +@Startup +class CacheInvalidationSender implements DistributedCacheInvalidation.Sender { + private static final Logger LOGGER = LoggerFactory.getLogger(CacheInvalidationSender.class); + + private final Vertx vertx; + private final long serviceNameLookupIntervalMillis; + + private final HttpClient httpClient; + private final AddressResolver addressResolver; + + private final List serviceNames; + private final int httpPort; + private final String invalidationUri; + private final long requestTimeout; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Lock lock = new ReentrantLock(); + private final int batchSize; + private final BlockingQueue invalidations = new LinkedBlockingQueue<>(); + private boolean triggered; + private final String token; + + /** Contains the IPv4/6 addresses resolved from {@link #serviceNames}. */ + private volatile List resolvedAddresses = emptyList(); + + @Inject + CacheInvalidationSender( + @SuppressWarnings("CdiInjectionPointsInspection") Vertx vertx, + QuarkusDistributedCacheInvalidationsConfig config, + @ConfigProperty(name = "quarkus.management.port") int httpPort, + ServerInstanceId serverInstanceId) { + this.vertx = vertx; + + this.addressResolver = new AddressResolver(vertx); + this.requestTimeout = + config + .cacheInvalidationRequestTimeout() + .orElse(Duration.of(30, ChronoUnit.SECONDS)) + .toMillis(); + this.httpClient = vertx.createHttpClient(); + this.serviceNames = config.cacheInvalidationServiceNames().orElse(emptyList()); + this.httpPort = httpPort; + this.invalidationUri = + config.cacheInvalidationUri() + "?sender=" + serverInstanceId.instanceId(); + this.serviceNameLookupIntervalMillis = + config.cacheInvalidationServiceNameLookupInterval().toMillis(); + this.batchSize = config.cacheInvalidationBatchSize(); + this.token = config.cacheInvalidationValidTokens().map(List::getFirst).orElse(null); + if (!serviceNames.isEmpty()) { + try { + LOGGER.info("Sending remote cache invalidations to service name(s) {}", serviceNames); + updateServiceNames().toCompletionStage().toCompletableFuture().get(); + if (config.cacheInvalidationValidTokens().isEmpty()) { + LOGGER.warn( + "No token configured for cache invalidation messages - will not send any invalidation message. You need to configure the token(s) via {}.{}", + CACHE_INVALIDATIONS_CONFIG_PREFIX, + CONFIG_VALID_TOKENS); + } + } catch (Exception e) { + throw new RuntimeException( + "Failed to resolve service names " + serviceNames + " for remote cache invalidations", + (e instanceof ExecutionException) ? e.getCause() : e); + } + } else if (token != null) { + LOGGER.warn( + "No service names are configured to send cache invalidation messages to - will not send any invalidation message. You need to configure the service name(s) via {}.{}", + CACHE_INVALIDATIONS_CONFIG_PREFIX, + CONFIG_SERVICE_NAMES); + } + } + + private Future> updateServiceNames() { + var previous = new HashSet<>(resolvedAddresses); + return resolveServiceNames(serviceNames) + .map( + all -> + all.stream().filter(adr -> !AddressResolver.LOCAL_ADDRESSES.contains(adr)).toList()) + .onSuccess( + all -> { + // refresh addresses regularly + scheduleServiceNameResolution(); + + var resolved = new HashSet<>(all); + if (!resolved.equals(previous)) { + LOGGER.info( + "Service names for remote cache invalidations {} now resolve to {}", + serviceNames, + all); + } + + updateResolvedAddresses(all); + }) + .onFailure( + t -> { + // refresh addresses regularly + scheduleServiceNameResolution(); + + LOGGER.warn("Failed to resolve service names: {}", t.toString()); + }); + } + + @VisibleForTesting + void updateResolvedAddresses(List all) { + resolvedAddresses = all; + } + + private void scheduleServiceNameResolution() { + vertx.setTimer(serviceNameLookupIntervalMillis, x -> updateServiceNames()); + } + + @VisibleForTesting + Future> resolveServiceNames(List serviceNames) { + return addressResolver.resolveAll(serviceNames); + } + + void enqueue(CacheInvalidation invalidation) { + if (serviceNames.isEmpty() || token == null) { + // Don't do anything if there are no targets to send invalidations to or whether no token has + // been configured. + return; + } + + lock.lock(); + try { + invalidations.add(invalidation); + + if (!triggered) { + LOGGER.trace("Triggered invalidation submission"); + vertx.executeBlocking(this::sendInvalidations); + triggered = true; + } + } finally { + lock.unlock(); + } + } + + private Void sendInvalidations() { + var batch = new ArrayList(batchSize); + try { + while (true) { + lock.lock(); + try { + invalidations.drainTo(batch, 100); + if (batch.isEmpty()) { + LOGGER.trace("Done sending invalidations"); + triggered = false; + break; + } + } finally { + lock.unlock(); + } + submit(batch, resolvedAddresses); + batch = new ArrayList<>(batchSize); + } + } finally { + // Handle the very unlikely case that the call to submit() failed and we cannot be sure that + // the current batch was submitted. + if (!batch.isEmpty()) { + lock.lock(); + try { + invalidations.addAll(batch); + triggered = false; + } finally { + lock.unlock(); + } + } + } + return null; + } + + @VisibleForTesting + List>> submit( + List batch, List resolvedAddresses) { + LOGGER.trace("Submitting {} invalidations", batch.size()); + + String json; + try { + json = objectMapper.writeValueAsString(cacheInvalidations(batch)); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + + var futures = + new ArrayList>>(resolvedAddresses.size()); + for (var address : resolvedAddresses) { + futures.add( + httpClient + .request(HttpMethod.POST, httpPort, address, invalidationUri) + .compose( + req -> + req.putHeader("Content-Type", APPLICATION_JSON) + .putHeader(CACHE_INVALIDATION_TOKEN_HEADER, token) + .send(json)) + .compose(resp -> resp.body().map(b -> Map.entry(resp, b))) + .timeout(requestTimeout, TimeUnit.MILLISECONDS) + .onComplete( + success -> { + var resp = success.getKey(); + var statusCode = resp.statusCode(); + if (statusCode != 200 && statusCode != 204) { + LOGGER.warn( + "{} cache invalidations could not be sent to {}:{}{} - HTTP {}/{} - body: {}", + batch.size(), + address, + httpPort, + invalidationUri, + statusCode, + resp.statusMessage(), + success.getValue()); + } else { + LOGGER.trace( + "{} cache invalidations sent to {}:{}", batch.size(), address, httpPort); + } + }, + failure -> { + if (failure instanceof SocketException + || failure instanceof UnknownHostException) { + LOGGER.warn( + "Technical network issue sending cache invalidations to {}:{}{} : {}", + address, + httpPort, + invalidationUri, + failure.getMessage()); + } else { + LOGGER.error( + "Technical failure sending cache invalidations to {}:{}{}", + address, + httpPort, + invalidationUri, + failure); + } + })); + } + return futures; + } + + @Override + public void evictReference(@Nonnull String repositoryId, @Nonnull String refName) { + enqueue(cacheInvalidationEvictReference(repositoryId, refName)); + } + + @Override + public void evictObj(@Nonnull String repositoryId, @Nonnull ObjRef objId) { + enqueue(cacheInvalidationEvictObj(repositoryId, objId)); + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/QuarkusDistributedCacheInvalidationsConfig.java b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/QuarkusDistributedCacheInvalidationsConfig.java new file mode 100644 index 0000000000..dc5c59cf21 --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/QuarkusDistributedCacheInvalidationsConfig.java @@ -0,0 +1,84 @@ +/* + * 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.polaris.persistence.nosql.quarkus.distcache; + +import static org.apache.polaris.persistence.nosql.quarkus.distcache.QuarkusDistributedCacheInvalidationsConfig.CACHE_INVALIDATIONS_CONFIG_PREFIX; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import io.smallrye.config.WithName; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import org.apache.polaris.immutables.PolarisImmutable; + +@PolarisImmutable +@ConfigMapping(prefix = CACHE_INVALIDATIONS_CONFIG_PREFIX) +public interface QuarkusDistributedCacheInvalidationsConfig { + + String CACHE_INVALIDATIONS_CONFIG_PREFIX = "polaris.persistence.distributed-cache-invalidations"; + String CONFIG_VALID_TOKENS = "valid-tokens"; + String CONFIG_SERVICE_NAMES = "service-names"; + String CONFIG_URI = "uri"; + String CONFIG_BATCH_SIZE = "batch-size"; + String CONFIG_SERVICE_NAME_LOOKUP_INTERVAL = "service-name-lookup-interval"; + String CONFIG_REQUEST_TIMEOUT = "request-timeout"; + + /** + * Host names or IP addresses or kubernetes headless-service name of all Polaris server instances + * accessing the same repository. + * + *

This value is automatically configured via the Polaris + * Helm chart or the Kubernetes operator (not released yet), you don't need any additional + * configuration for distributed cache invalidations - it's setup and configured automatically. If + * you have your own Helm chart or custom deployment, make sure to configure the IPs of all + * Polaris instances here. + * + *

Names that start with an equal sign are not resolved but used "as is". + */ + @WithName(CONFIG_SERVICE_NAMES) + Optional> cacheInvalidationServiceNames(); + + /** List of cache-invalidation tokens to authenticate incoming cache-invalidation messages. */ + @WithName(CONFIG_VALID_TOKENS) + Optional> cacheInvalidationValidTokens(); + + /** + * URI of the cache-invalidation endpoint, only available on the Quarkus management port, defaults + * to 9000. + */ + @WithName(CONFIG_URI) + @WithDefault("/polaris-management/cache-coherency") + String cacheInvalidationUri(); + + /** + * Interval of service-name lookups to resolve the {@linkplain #cacheInvalidationServiceNames() + * service names} into IP addresses. + */ + @WithName(CONFIG_SERVICE_NAME_LOOKUP_INTERVAL) + @WithDefault("PT10S") + Duration cacheInvalidationServiceNameLookupInterval(); + + @WithName(CONFIG_BATCH_SIZE) + @WithDefault("20") + int cacheInvalidationBatchSize(); + + @WithName(CONFIG_REQUEST_TIMEOUT) + Optional cacheInvalidationRequestTimeout(); +} diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/ResolvConf.java b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/ResolvConf.java new file mode 100644 index 0000000000..b00a317f97 --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/ResolvConf.java @@ -0,0 +1,124 @@ +/* + * 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.polaris.persistence.nosql.quarkus.distcache; + +// Code mostly copied from io.netty.resolver.dns.ResolvConf, but with the addition to extract +// the 'search' option values. +// +// Marker for Polaris LICENSE file - keep it +// CODE_COPIED_TO_POLARIS + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.unmodifiableList; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Looks up the {@code nameserver}s and {@code search} domains from the {@code /etc/resolv.conf} + * file, intended for Linux and macOS. + */ +final class ResolvConf { + private final List nameservers; + private final List searchList; + + /** + * Reads from the given reader and extracts the {@code nameserver}s and {@code search} domains + * using the syntax of the {@code /etc/resolv.conf} file, see {@code man resolv.conf}. + * + * @param reader contents of {@code resolv.conf} are read from this {@link BufferedReader}, up to + * the caller to close it + */ + static ResolvConf fromReader(BufferedReader reader) throws IOException { + return new ResolvConf(reader); + } + + /** + * Reads the given file and extracts the {@code nameserver}s and {@code search} domains using the + * syntax of the {@code /etc/resolv.conf} file, see {@code man resolv.conf}. + */ + static ResolvConf fromFile(String file) throws IOException { + try (var fileReader = new FileReader(file, UTF_8); + BufferedReader reader = new BufferedReader(fileReader)) { + return fromReader(reader); + } + } + + /** + * Returns the {@code nameserver}s and {@code search} domains from the {@code /etc/resolv.conf} + * file. The file is only read once during the lifetime of this class. + */ + static ResolvConf system() { + var resolvConv = ResolvConfLazy.machineResolvConf; + if (resolvConv != null) { + return resolvConv; + } + throw new IllegalStateException("/etc/resolv.conf could not be read"); + } + + private ResolvConf(BufferedReader reader) throws IOException { + var nameservers = new ArrayList(); + var searchList = new ArrayList(); + String ln; + while ((ln = reader.readLine()) != null) { + ln = ln.trim(); + if (ln.isEmpty()) { + continue; + } + + if (ln.startsWith("nameserver")) { + ln = ln.substring("nameserver".length()).trim(); + nameservers.add(new InetSocketAddress(ln, 53)); + } + if (ln.startsWith("search")) { + ln = ln.substring("search".length()).trim(); + searchList.addAll(Arrays.asList(ln.split(" "))); + } + } + this.nameservers = unmodifiableList(nameservers); + this.searchList = unmodifiableList(searchList); + } + + List getNameservers() { + return nameservers; + } + + List getSearchList() { + return searchList; + } + + private static final class ResolvConfLazy { + static final ResolvConf machineResolvConf; + + static { + ResolvConf resolvConf; + try { + resolvConf = ResolvConf.fromFile("/etc/resolv.conf"); + } catch (IOException e) { + resolvConf = null; + } + machineResolvConf = resolvConf; + } + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/ServerInstanceId.java b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/ServerInstanceId.java new file mode 100644 index 0000000000..f39c0540f4 --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/src/main/java/org/apache/polaris/persistence/nosql/quarkus/distcache/ServerInstanceId.java @@ -0,0 +1,30 @@ +/* + * 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.polaris.persistence.nosql.quarkus.distcache; + +import org.apache.polaris.immutables.PolarisImmutable; + +@PolarisImmutable +interface ServerInstanceId { + String instanceId(); + + static ServerInstanceId of(String instanceId) { + return ImmutableServerInstanceId.builder().instanceId(instanceId).build(); + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/HttpTestServer.java b/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/HttpTestServer.java new file mode 100644 index 0000000000..c89a44545e --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/HttpTestServer.java @@ -0,0 +1,70 @@ +/* + * 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.polaris.persistence.nosql.quarkus.distcache; + +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; + +/** HTTP test server. */ +public class HttpTestServer implements AutoCloseable { + private final HttpServer server; + + public HttpTestServer(String context, HttpHandler handler) throws IOException { + this(new InetSocketAddress("localhost", 0), context, handler); + } + + public HttpTestServer(InetSocketAddress bind, String context, HttpHandler handler) + throws IOException { + HttpHandler safeHandler = + exchange -> { + try { + handler.handle(exchange); + } catch (RuntimeException | Error e) { + exchange.sendResponseHeaders(503, 0); + throw e; + } + }; + server = HttpServer.create(bind, 0); + server.createContext(context, safeHandler); + server.setExecutor(null); + + server.start(); + } + + public InetSocketAddress getAddress() { + return server.getAddress(); + } + + public URI getUri() { + return URI.create( + "http://" + + getAddress().getAddress().getHostAddress() + + ":" + + getAddress().getPort() + + "/"); + } + + @Override + public void close() { + server.stop(0); + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/TestAddressResolver.java b/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/TestAddressResolver.java new file mode 100644 index 0000000000..3f788cc60f --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/TestAddressResolver.java @@ -0,0 +1,170 @@ +/* + * 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.polaris.persistence.nosql.quarkus.distcache; + +import static java.util.Collections.singletonList; +import static java.util.stream.Collectors.toList; +import static org.apache.polaris.persistence.nosql.quarkus.distcache.AddressResolver.LOCAL_ADDRESSES; + +import io.vertx.core.Vertx; +import io.vertx.core.dns.DnsException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestAddressResolver { + @InjectSoftAssertions protected SoftAssertions soft; + + protected Vertx vertx; + AddressResolver addressResolver; + + @BeforeEach + void setUp() { + vertx = Vertx.builder().build(); + } + + @AfterEach + void tearDown() throws Exception { + try { + if (addressResolver != null) { + addressResolver + .dnsClient() + .close() + .toCompletionStage() + .toCompletableFuture() + .get(1, TimeUnit.MINUTES); + } + } finally { + try { + vertx.close().toCompletionStage().toCompletableFuture().get(1, TimeUnit.MINUTES); + } finally { + vertx = null; + } + } + } + + @Test + public void resolveNoName() throws Exception { + addressResolver = new AddressResolver(vertx); + soft.assertThat( + addressResolver + .resolveAll(Collections.emptyList()) + .toCompletionStage() + .toCompletableFuture() + .get(1, TimeUnit.MINUTES)) + .isEmpty(); + } + + @Test + public void resolveGoodName() throws Exception { + addressResolver = new AddressResolver(vertx); + + AddressResolver addressResolverWithSearch = + new AddressResolver(addressResolver.dnsClient(), List.of("org")); + + List withoutSearchList = + addressResolver + .resolveAll(singletonList("projectnessie.org")) + .toCompletionStage() + .toCompletableFuture() + .get(1, TimeUnit.MINUTES); + soft.assertThat(withoutSearchList).isNotEmpty(); + + List withSearchList1 = + addressResolverWithSearch + .resolveAll(singletonList("projectnessie.org")) + .toCompletionStage() + .toCompletableFuture() + .get(1, TimeUnit.MINUTES); + soft.assertThat(withoutSearchList).isNotEmpty(); + soft.assertThat(withSearchList1).isNotEmpty().isNotEmpty(); + soft.assertThat(withSearchList1).containsExactlyInAnyOrderElementsOf(withoutSearchList); + + List withSearchList2 = + addressResolverWithSearch + .resolveAll(singletonList("projectnessie")) + .toCompletionStage() + .toCompletableFuture() + .get(1, TimeUnit.MINUTES); + soft.assertThat(withSearchList2).isNotEmpty(); + soft.assertThat(withSearchList2).containsExactlyInAnyOrderElementsOf(withoutSearchList); + + List withSearchListQualified = + addressResolverWithSearch + .resolveAll(singletonList("projectnessie.org.")) + .toCompletionStage() + .toCompletableFuture() + .get(1, TimeUnit.MINUTES); + soft.assertThat(withSearchListQualified).isNotEmpty(); + + soft.assertThat(withSearchListQualified).containsExactlyInAnyOrderElementsOf(withoutSearchList); + } + + @Test + @DisabledOnOs(value = OS.MAC, disabledReason = "Resolving 'localhost' doesn't work on macOS") + public void resolveSingleName() throws Exception { + addressResolver = new AddressResolver(vertx); + soft.assertThat( + addressResolver + .resolveAll(singletonList("localhost")) + .toCompletionStage() + .toCompletableFuture() + .get(1, TimeUnit.MINUTES)) + .isNotEmpty() + .containsAnyOf("0:0:0:0:0:0:0:1", "127.0.0.1"); + } + + @Test + public void resolveBadName() { + addressResolver = new AddressResolver(vertx); + soft.assertThat( + addressResolver + .resolveAll(singletonList("wepofkjeopiwkf.wepofkeowpkfpoew.weopfkewopfk.local")) + .toCompletionStage() + .toCompletableFuture()) + .failsWithin(1, TimeUnit.MINUTES) + .withThrowableThat() + .withCauseInstanceOf(DnsException.class); + } + + @Test + @DisabledOnOs(value = OS.MAC, disabledReason = "Resolving 'localhost' doesn't work on macOS") + public void resolveFilterLocalAddresses() throws Exception { + addressResolver = new AddressResolver(vertx); + soft.assertThat( + addressResolver + .resolveAll(singletonList("localhost")) + .map( + s -> s.stream().filter(adr -> !LOCAL_ADDRESSES.contains(adr)).collect(toList())) + .toCompletionStage() + .toCompletableFuture() + .get(1, TimeUnit.MINUTES)) + .isEmpty(); + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/TestCacheInvalidationReceiver.java b/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/TestCacheInvalidationReceiver.java new file mode 100644 index 0000000000..e6e7d4073f --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/TestCacheInvalidationReceiver.java @@ -0,0 +1,186 @@ +/* + * 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.polaris.persistence.nosql.quarkus.distcache; + +import static java.util.Collections.singletonList; +import static org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.CacheInvalidationEvictObj.cacheInvalidationEvictObj; +import static org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.CacheInvalidationEvictReference.cacheInvalidationEvictReference; +import static org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.cacheInvalidations; +import static org.apache.polaris.persistence.nosql.quarkus.distcache.CacheInvalidationReceiver.CACHE_INVALIDATION_TOKEN_HEADER; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.vertx.core.Future; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RequestBody; +import io.vertx.ext.web.RoutingContext; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations; +import org.apache.polaris.persistence.nosql.api.cache.DistributedCacheInvalidation; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.junit.jupiter.api.Test; + +public class TestCacheInvalidationReceiver { + private static final ObjRef SOME_OBJ_REF = ObjRef.objRef("foo", 1234); + + @Test + public void senderReceiver() throws Exception { + var distributedCacheInvalidation = mock(DistributedCacheInvalidation.Receiver.class); + + var token = "cafe"; + var tokens = singletonList(token); + var receiverId = ServerInstanceId.of("receiverId"); + var senderId = ServerInstanceId.of("senderId"); + + var receiver = buildReceiver(tokens, receiverId, distributedCacheInvalidation); + + var invalidations = cacheInvalidations(allInvalidationTypes()); + + var rc = + expectResponse( + r -> { + when(r.getParam("sender")).thenReturn(senderId.instanceId()); + when(r.getHeader(CACHE_INVALIDATION_TOKEN_HEADER)).thenReturn(token); + }); + var reqBody = mock(RequestBody.class); + when(reqBody.asString()).thenReturn(new ObjectMapper().writeValueAsString(invalidations)); + when(rc.body()).thenReturn(reqBody); + + receiver.cacheInvalidations(rc); + + verify(rc.response()).setStatusCode(204); + verify(rc.response()).setStatusMessage("No content"); + + verify(distributedCacheInvalidation).evictObj("repo", SOME_OBJ_REF); + verify(distributedCacheInvalidation).evictReference("repo", "refs/foo/bar"); + verifyNoMoreInteractions(distributedCacheInvalidation); + } + + @Test + public void doesNotAcceptInvalidationsWithoutTokens() { + var distributedCacheInvalidation = mock(DistributedCacheInvalidation.Receiver.class); + + var token = "cafe"; + var tokens = List.of(); + var receiverId = ServerInstanceId.of("receiverId"); + var senderId = ServerInstanceId.of("senderId"); + + var receiver = buildReceiver(tokens, receiverId, distributedCacheInvalidation); + + var rc = expectResponse(); + receiver.cacheInvalidations( + rc, () -> cacheInvalidations(allInvalidationTypes()), senderId.instanceId(), token); + + verify(rc.response()).setStatusCode(400); + verify(rc.response()).setStatusMessage("Invalid token"); + + verifyNoMoreInteractions(distributedCacheInvalidation); + } + + @Test + public void receiveFromSelf() { + var distributedCacheInvalidation = mock(DistributedCacheInvalidation.Receiver.class); + + var token = "cafe"; + var tokens = singletonList(token); + var receiverId = ServerInstanceId.of("receiverId"); + + var receiver = buildReceiver(tokens, receiverId, distributedCacheInvalidation); + + var rc = expectResponse(); + receiver.cacheInvalidations( + rc, () -> cacheInvalidations(allInvalidationTypes()), receiverId.instanceId(), token); + + verify(rc.response()).setStatusCode(204); + verify(rc.response()).setStatusMessage("No content"); + + verifyNoMoreInteractions(distributedCacheInvalidation); + } + + @Test + public void unknownToken() { + var distributedCacheInvalidation = mock(DistributedCacheInvalidation.Receiver.class); + + var token = "cafe"; + var tokens = singletonList(token); + var differentToken = "otherToken"; + var receiverId = ServerInstanceId.of("receiverId"); + var senderId = ServerInstanceId.of("senderId"); + + CacheInvalidationReceiver receiver = + buildReceiver(tokens, receiverId, distributedCacheInvalidation); + + RoutingContext rc = expectResponse(); + receiver.cacheInvalidations( + rc, + () -> cacheInvalidations(allInvalidationTypes()), + senderId.instanceId(), + differentToken); + + verify(rc.response()).setStatusCode(400); + verify(rc.response()).setStatusMessage("Invalid token"); + + verifyNoMoreInteractions(distributedCacheInvalidation); + } + + private RoutingContext expectResponse() { + return expectResponse(r -> {}); + } + + private RoutingContext expectResponse(Consumer requestMocker) { + var response = mock(HttpServerResponse.class); + when(response.setStatusCode(anyInt())).thenReturn(response); + when(response.setStatusMessage(anyString())).thenReturn(response); + when(response.end()).thenReturn(Future.succeededFuture()); + + var request = mock(HttpServerRequest.class); + when(request.getHeader("Content-Type")).thenReturn("application/json"); + requestMocker.accept(request); + + var rc = mock(RoutingContext.class); + when(rc.response()).thenReturn(response); + when(rc.request()).thenReturn(request); + return rc; + } + + private static CacheInvalidationReceiver buildReceiver( + List tokens, + ServerInstanceId receiverId, + DistributedCacheInvalidation.Receiver distCacheInvalidation) { + QuarkusDistributedCacheInvalidationsConfig config = + mock(QuarkusDistributedCacheInvalidationsConfig.class); + when(config.cacheInvalidationValidTokens()).thenReturn(Optional.of(tokens)); + + return new CacheInvalidationReceiver(config, receiverId, distCacheInvalidation); + } + + List allInvalidationTypes() { + return List.of( + cacheInvalidationEvictReference("repo", "refs/foo/bar"), + cacheInvalidationEvictObj("repo", SOME_OBJ_REF)); + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/TestCacheInvalidationSender.java b/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/TestCacheInvalidationSender.java new file mode 100644 index 0000000000..7f3ef3d3aa --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/TestCacheInvalidationSender.java @@ -0,0 +1,565 @@ +/* + * 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.polaris.persistence.nosql.quarkus.distcache; + +import static io.vertx.core.Future.failedFuture; +import static io.vertx.core.Future.succeededFuture; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.singletonList; +import static org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.CacheInvalidationEvictObj.cacheInvalidationEvictObj; +import static org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.CacheInvalidationEvictReference.cacheInvalidationEvictReference; +import static org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.cacheInvalidations; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientResponse; +import java.io.InputStream; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.CacheInvalidation; +import org.apache.polaris.persistence.nosql.api.cache.DistributedCacheInvalidation; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestCacheInvalidationSender { + private static final ObjRef SOME_OBJ_REF = ObjRef.objRef("foo", 1234); + + @InjectSoftAssertions protected SoftAssertions soft; + + protected Vertx vertx; + + @BeforeEach + void setUp() { + vertx = Vertx.builder().build(); + } + + @AfterEach + void tearDown() throws Exception { + try { + vertx.close().toCompletionStage().toCompletableFuture().get(1, TimeUnit.MINUTES); + } finally { + vertx = null; + } + } + + @Test + public void serviceNameLookupFailure() { + var senderId = ServerInstanceId.of("senderId"); + + var token = "token"; + var tokens = singletonList(token); + + var config = + buildConfig( + tokens, + Optional.of(singletonList("serviceName")), + Duration.ofSeconds(10), + Duration.ofSeconds(10)); + + soft.assertThatThrownBy( + () -> + new CacheInvalidationSender(vertx, config, 80, senderId) { + @Override + Future> resolveServiceNames(List serviceNames) { + return failedFuture(new RuntimeException("foo")); + } + + @Override + List>> submit( + List batch, List resolvedAddresses) { + soft.fail("Not expected"); + return null; + } + }) + .hasMessage("Failed to resolve service names [serviceName] for remote cache invalidations") + .cause() + .hasMessage("foo"); + } + + @Test + public void regularServiceNameLookups() throws Exception { + var senderId = ServerInstanceId.of("senderId"); + + var token = "token"; + var tokens = singletonList(token); + + var config = + buildConfig( + tokens, + Optional.of(singletonList("serviceName")), + Duration.ofMillis(1), + Duration.ofSeconds(10)); + + var resolveSemaphore = new Semaphore(1); + var continueSemaphore = new Semaphore(0); + var submittedSemaphore = new Semaphore(0); + var updateResolvedSemaphore = new Semaphore(0); + var currentAddresses = List.of("127.1.1.1"); + var resolveResult = new AtomicReference<>(succeededFuture(currentAddresses)); + var submitResolvedAddresses = new AtomicReference>(); + + try { + CacheInvalidationSender sender = + new CacheInvalidationSender(vertx, config, 80, senderId) { + @Override + Future> resolveServiceNames(List serviceNames) { + try { + assertThat(resolveSemaphore.tryAcquire(30, TimeUnit.SECONDS)).isTrue(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + try { + return resolveResult.get(); + } finally { + continueSemaphore.release(); + } + } + + @Override + void updateResolvedAddresses(List all) { + try { + super.updateResolvedAddresses(all); + } finally { + updateResolvedSemaphore.release(); + } + } + + @Override + List>> submit( + List batch, List resolvedAddresses) { + submitResolvedAddresses.set(resolvedAddresses); + submittedSemaphore.release(); + return null; + } + }; + + // "consume" after initial, blocking call to resolveServiceNames() from the constructor + assertThat(continueSemaphore.tryAcquire(30, TimeUnit.SECONDS)).isTrue(); + assertThat(updateResolvedSemaphore.tryAcquire(30, TimeUnit.SECONDS)).isTrue(); + + // Send an invalidation, compare addresses + sender.evictObj("repo", SOME_OBJ_REF); + assertThat(submittedSemaphore.tryAcquire(30, TimeUnit.SECONDS)).isTrue(); + soft.assertThat(submitResolvedAddresses.get()) + .containsExactlyInAnyOrderElementsOf(currentAddresses); + + // simulate change of resolved addresses + currentAddresses = List.of("127.2.2.2", "127.3.3.3"); + resolveResult.set(succeededFuture(currentAddresses)); + resolveSemaphore.release(); + // wait until next call to resolveServiceNames() has been triggered + assertThat(continueSemaphore.tryAcquire(30, TimeUnit.SECONDS)).isTrue(); + assertThat(updateResolvedSemaphore.tryAcquire(30, TimeUnit.SECONDS)).isTrue(); + + // Send another invalidation, compare addresses + sender.evictObj("repo", SOME_OBJ_REF); + assertThat(submittedSemaphore.tryAcquire(30, TimeUnit.SECONDS)).isTrue(); + soft.assertThat(submitResolvedAddresses.get()) + .containsExactlyInAnyOrderElementsOf(currentAddresses); + + // simulate a failure resolving the addresses + resolveResult.set(failedFuture(new RuntimeException("blah"))); + resolveSemaphore.release(); + // wait until next call to resolveServiceNames() has been triggered + assertThat(continueSemaphore.tryAcquire(30, TimeUnit.SECONDS)).isTrue(); + + // Send another invalidation, compare addresses + sender.evictObj("repo", SOME_OBJ_REF); + assertThat(submittedSemaphore.tryAcquire(30, TimeUnit.SECONDS)).isTrue(); + soft.assertThat(submitResolvedAddresses.get()) + .containsExactlyInAnyOrderElementsOf(currentAddresses); + + // simulate another change of resolved addresses + currentAddresses = List.of("127.4.4.4", "127.5.5.5"); + resolveResult.set(succeededFuture(currentAddresses)); + resolveSemaphore.release(); + // wait until next call to resolveServiceNames() has been triggered + assertThat(continueSemaphore.tryAcquire(30, TimeUnit.SECONDS)).isTrue(); + assertThat(updateResolvedSemaphore.tryAcquire(30, TimeUnit.SECONDS)).isTrue(); + + // Send another invalidation, compare addresses + sender.evictObj("repo", SOME_OBJ_REF); + assertThat(submittedSemaphore.tryAcquire(30, TimeUnit.SECONDS)).isTrue(); + soft.assertThat(submitResolvedAddresses.get()) + .containsExactlyInAnyOrderElementsOf(currentAddresses); + } finally { + // Permit a lot, the test might otherwise "hang" in resolveServiceNames() + resolveSemaphore.release(10_000_000); + } + } + + @Test + public void noServiceNames() throws Exception { + var senderId = ServerInstanceId.of("senderId"); + + var token = "token"; + var tokens = singletonList(token); + + var config = + buildConfig(tokens, Optional.empty(), Duration.ofSeconds(10), Duration.ofSeconds(10)); + + var sender = + new CacheInvalidationSender(vertx, config, 80, senderId) { + @Override + Future> resolveServiceNames(List serviceNames) { + return succeededFuture(List.of()); + } + + @Override + List>> submit( + List batch, List resolvedAddresses) { + soft.fail("Not expected"); + return null; + } + }; + + var senderSpy = spy(sender); + + senderSpy.evictObj("repo", SOME_OBJ_REF); + + // Hard to test that nothing is done, if the list of resolved addresses is empty, but the + // condition is easy. If this tests is flaky, then there's something broken. + Thread.sleep(100L); + + verify(senderSpy).evictObj("repo", SOME_OBJ_REF); + verify(senderSpy).enqueue(cacheInvalidationEvictObj("repo", SOME_OBJ_REF)); + verifyNoMoreInteractions(senderSpy); + } + + @ParameterizedTest + @MethodSource("invalidations") + public void mockedSendSingleInvalidation( + Consumer invalidation, CacheInvalidation expected) + throws Exception { + var senderId = ServerInstanceId.of("senderId"); + + var token = "token"; + var tokens = singletonList(token); + + var serviceNames = singletonList("service-name"); + var resolvedServiceNames = singletonList("service-name-resolved"); + + var config = + buildConfig( + tokens, Optional.of(serviceNames), Duration.ofSeconds(10), Duration.ofSeconds(10)); + + var sem = new Semaphore(0); + var sender = + new CacheInvalidationSender(vertx, config, 80, senderId) { + @Override + Future> resolveServiceNames(List serviceNames) { + return succeededFuture(resolvedServiceNames); + } + + @Override + List>> submit( + List batch, List resolvedAddresses) { + sem.release(1); + return null; + } + }; + + var senderSpy = spy(sender); + + invalidation.accept(senderSpy); + assertThat(sem.tryAcquire(30, TimeUnit.SECONDS)).isTrue(); + + verify(senderSpy).submit(singletonList(expected), resolvedServiceNames); + } + + @Test + public void mockedAllInvalidationTypes() throws Exception { + var senderId = ServerInstanceId.of("senderId"); + + var token = "token"; + var tokens = singletonList(token); + + var serviceNames = singletonList("service-name"); + var resolvedServiceNames = singletonList("service-name-resolved"); + + var config = + buildConfig( + tokens, Optional.of(serviceNames), Duration.ofSeconds(10), Duration.ofSeconds(10)); + + var sem = new Semaphore(0); + var received = new ConcurrentLinkedQueue<>(); + var sender = + new CacheInvalidationSender(vertx, config, 80, senderId) { + @Override + Future> resolveServiceNames(List serviceNames) { + return succeededFuture(resolvedServiceNames); + } + + @Override + List>> submit( + List batch, List resolvedAddresses) { + received.addAll(batch); + soft.assertThat(resolvedAddresses) + .containsExactlyInAnyOrderElementsOf(resolvedServiceNames); + sem.release(batch.size()); + return null; + } + }; + + var senderSpy = spy(sender); + + var expected = + invalidations().map(args -> args.get()[1]).map(CacheInvalidation.class::cast).toList(); + + invalidations() + .map(args -> args.get()[0]) + .map( + i -> { + @SuppressWarnings({"UnnecessaryLocalVariable", "unchecked"}) + Consumer r = (Consumer) i; + return r; + }) + .forEach(i -> i.accept(senderSpy)); + + assertThat(sem.tryAcquire(expected.size(), 30, TimeUnit.SECONDS)).isTrue(); + + soft.assertThat(received).containsExactlyInAnyOrderElementsOf(expected); + } + + @ParameterizedTest + @MethodSource("invalidations") + public void sendSingleInvalidation( + @SuppressWarnings("unused") Consumer invalidation, + CacheInvalidation expected) + throws Exception { + var senderId = ServerInstanceId.of("senderId"); + + var token = "token"; + var tokens = singletonList(token); + + var serviceNames = singletonList("service-name"); + + var config = + buildConfig( + tokens, Optional.of(serviceNames), Duration.ofSeconds(10), Duration.ofSeconds(10)); + + var mapper = new ObjectMapper(); + + var body = new AtomicReference(); + var reqUri = new AtomicReference(); + try (var receiver = + new HttpTestServer( + config.cacheInvalidationUri(), + exchange -> { + try (InputStream requestBody = exchange.getRequestBody()) { + body.set(new String(requestBody.readAllBytes(), UTF_8)); + } + reqUri.set(exchange.getRequestURI()); + exchange.sendResponseHeaders(204, 0); + exchange.getResponseBody().close(); + })) { + + var uri = receiver.getUri(); + + var sender = + new CacheInvalidationSender(vertx, config, uri.getPort(), senderId) { + @Override + Future> resolveServiceNames(List serviceNames) { + return succeededFuture(List.of(uri.getHost())); + } + }; + + var future = + CompletableFuture.allOf( + sender.submit(singletonList(expected), singletonList(uri.getHost())).stream() + .map(Future::toCompletionStage) + .map(CompletionStage::toCompletableFuture) + .toArray(CompletableFuture[]::new)); + + soft.assertThat(future).succeedsWithin(30, TimeUnit.SECONDS); + + soft.assertThat(body.get()) + .isEqualTo(mapper.writeValueAsString(cacheInvalidations(singletonList(expected)))); + soft.assertThat(reqUri.get()).extracting(URI::getPath).isEqualTo("/foo/bar/"); + soft.assertThat(reqUri.get()) + .extracting(URI::getQuery) + .isEqualTo("sender=" + senderId.instanceId()); + } + } + + @Test + public void allInvalidationTypes() throws Exception { + var senderId = ServerInstanceId.of("senderId"); + + var token = "token"; + var tokens = singletonList(token); + + var serviceNames = singletonList("service-name"); + + var config = + buildConfig( + tokens, Optional.of(serviceNames), Duration.ofSeconds(10), Duration.ofSeconds(30)); + + var expected = + invalidations().map(args -> args.get()[1]).map(CacheInvalidation.class::cast).toList(); + + var mapper = new ObjectMapper(); + + var body = new AtomicReference(); + var reqUri = new AtomicReference(); + try (HttpTestServer receiver = + new HttpTestServer( + config.cacheInvalidationUri(), + exchange -> { + try (InputStream requestBody = exchange.getRequestBody()) { + body.set(new String(requestBody.readAllBytes(), UTF_8)); + } + reqUri.set(exchange.getRequestURI()); + exchange.sendResponseHeaders(204, 0); + exchange.getResponseBody().close(); + })) { + + var uri = receiver.getUri(); + + var sender = + new CacheInvalidationSender(vertx, config, uri.getPort(), senderId) { + @Override + Future> resolveServiceNames(List serviceNames) { + return succeededFuture(List.of(uri.getHost())); + } + }; + + var future = + Future.all(sender.submit(expected, singletonList(uri.getHost()))) + .toCompletionStage() + .toCompletableFuture(); + + soft.assertThat(future).succeedsWithin(30, TimeUnit.SECONDS); + + soft.assertThat(body.get()) + .isEqualTo(mapper.writeValueAsString(cacheInvalidations(expected))); + soft.assertThat(reqUri.get()).extracting(URI::getPath).isEqualTo("/foo/bar/"); + soft.assertThat(reqUri.get()) + .extracting(URI::getQuery) + .isEqualTo("sender=" + senderId.instanceId()); + } + } + + @Test + public void sendInvalidationTimeout() throws Exception { + var senderId = ServerInstanceId.of("senderId"); + + var token = "token"; + var tokens = singletonList(token); + + var serviceNames = singletonList("service-name"); + + var config = + buildConfig( + tokens, Optional.of(serviceNames), Duration.ofSeconds(10), Duration.ofMillis(1)); + + var expected = + invalidations().map(args -> args.get()[1]).map(CacheInvalidation.class::cast).toList(); + + try (var receiver = + new HttpTestServer( + config.cacheInvalidationUri(), + exchange -> { + try (InputStream requestBody = exchange.getRequestBody()) { + requestBody.readAllBytes(); + } + // don't send a response -> provoke a timeout + exchange.getResponseBody().close(); + })) { + + var uri = receiver.getUri(); + + var sender = + new CacheInvalidationSender(vertx, config, uri.getPort(), senderId) { + @Override + Future> resolveServiceNames(List serviceNames) { + return succeededFuture(List.of(uri.getHost())); + } + }; + + var future = + CompletableFuture.allOf( + sender.submit(expected, singletonList(uri.getHost())).stream() + .map(Future::toCompletionStage) + .map(CompletionStage::toCompletableFuture) + .toArray(CompletableFuture[]::new)); + + soft.assertThat(future) + .failsWithin(30, TimeUnit.SECONDS) + .withThrowableOfType(ExecutionException.class) + .withMessageContaining("Timeout 1 (ms) fired"); + } + } + + static Stream invalidations() { + return Stream.of( + arguments( + (Consumer) i -> i.evictObj("repo", SOME_OBJ_REF), + cacheInvalidationEvictObj("repo", SOME_OBJ_REF)), + arguments( + (Consumer) i -> i.evictReference("repo", "refs/foo/bar"), + cacheInvalidationEvictReference("repo", "refs/foo/bar"))); + } + + private static QuarkusDistributedCacheInvalidationsConfig buildConfig( + List tokens, + Optional> serviceName, + Duration interval, + Duration requestTimeout) { + var config = mock(QuarkusDistributedCacheInvalidationsConfig.class); + when(config.cacheInvalidationValidTokens()).thenReturn(Optional.of(tokens)); + when(config.cacheInvalidationServiceNames()).thenReturn(serviceName); + when(config.cacheInvalidationServiceNameLookupInterval()).thenReturn(interval); + when(config.cacheInvalidationBatchSize()).thenReturn(10); + when(config.cacheInvalidationUri()).thenReturn("/foo/bar/"); + when(config.cacheInvalidationRequestTimeout()).thenReturn(Optional.of(requestTimeout)); + return config; + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/TestResolvConf.java b/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/TestResolvConf.java new file mode 100644 index 0000000000..38a9555a66 --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/java/org/apache/polaris/persistence/nosql/quarkus/distcache/TestResolvConf.java @@ -0,0 +1,108 @@ +/* + * 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.polaris.persistence.nosql.quarkus.distcache; + +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Stream; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestResolvConf { + @InjectSoftAssertions protected SoftAssertions soft; + + @ParameterizedTest + @MethodSource + public void resolve( + String resolvConfContent, List nameservers, List searchList) + throws Exception { + ResolvConf resolvConf = + ResolvConf.fromReader(new BufferedReader(new StringReader(resolvConfContent))); + soft.assertThat(resolvConf) + .extracting(ResolvConf::getNameservers, ResolvConf::getSearchList) + .containsExactly(nameservers, searchList); + } + + @Test + public void system() throws IOException { + String file = Files.readString(Paths.get("/etc/resolv.conf")); + + ResolvConf resolvConf = ResolvConf.system(); + soft.assertThat(resolvConf.getNameservers()).isNotEmpty(); + // This 'if' ensures that this test passes on the macOS test run in CI. + if (file.contains("\nsearch ") || file.startsWith("search ")) { + soft.assertThat(resolvConf.getSearchList()).isNotEmpty(); + } else { + soft.assertThat(resolvConf.getSearchList()).isEmpty(); + } + } + + static Stream resolve() { + return Stream.of( + arguments( + """ + # See man:systemd-resolved.service(8) for details about the supported modes of + # operation for /etc/resolv.conf. + + nameserver 127.0.0.1 + search search.domain + """, + List.of(new InetSocketAddress("127.0.0.1", 53)), + List.of("search.domain")), + arguments( + """ + nameserver 127.0.0.1 + nameserver 1.2.3.4 + """, + List.of(new InetSocketAddress("127.0.0.1", 53), new InetSocketAddress("1.2.3.4", 53)), + List.of()), + arguments( + """ + nameserver 127.0.0.1 + nameserver 1.2.3.4 + search search.domain + search anothersearch.anotherdomain + """, + List.of(new InetSocketAddress("127.0.0.1", 53), new InetSocketAddress("1.2.3.4", 53)), + List.of("search.domain", "anothersearch.anotherdomain")), + arguments( + """ + nameserver 127.0.0.1 + nameserver 1.2.3.4 + search search.domain anothersearch.anotherdomain + """, + List.of(new InetSocketAddress("127.0.0.1", 53), new InetSocketAddress("1.2.3.4", 53)), + List.of("search.domain", "anothersearch.anotherdomain")), + arguments("", List.of(), List.of())); + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/resources/logback-test.xml b/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..92a43a7083 --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus-distcache/src/test/resources/logback-test.xml @@ -0,0 +1,36 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/persistence/nosql/persistence/cdi/quarkus/build.gradle.kts b/persistence/nosql/persistence/cdi/quarkus/build.gradle.kts new file mode 100644 index 0000000000..3e680926c5 --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus/build.gradle.kts @@ -0,0 +1,72 @@ +/* + * 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris NoSQL persistence, providers for Quarkus." + +dependencies { + implementation(project(":polaris-persistence-nosql-cdi-common")) + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-persistence-nosql-inmemory")) + implementation(project(":polaris-persistence-nosql-mongodb")) + implementation(project(":polaris-idgen-api")) + runtimeOnly(project(":polaris-nodes-impl")) + runtimeOnly(project(":polaris-nodes-store-nosql")) + runtimeOnly(project(":polaris-persistence-nosql-realms-impl")) + runtimeOnly(project(":polaris-persistence-nosql-realms-store-nosql")) + runtimeOnly(project(":polaris-async-vertx")) + runtimeOnly(project(":polaris-idgen-impl")) + runtimeOnly(project(":polaris-persistence-nosql-authz-impl")) + runtimeOnly(project(":polaris-persistence-nosql-authz-store-nosql")) + + compileOnly(platform(libs.micrometer.bom)) + compileOnly("io.micrometer:micrometer-core") + compileOnly(platform(libs.opentelemetry.instrumentation.bom.alpha)) + compileOnly("io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations") + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-annotations") + implementation("com.fasterxml.jackson.core:jackson-databind") + + compileOnly(libs.smallrye.config.core) + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + + implementation(platform(libs.quarkus.bom)) + implementation("io.quarkus:quarkus-core") + implementation("io.quarkus:quarkus-mongodb-client") + + implementation(libs.jakarta.ws.rs.api) + + implementation(libs.guava) + implementation(libs.slf4j.api) + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) +} diff --git a/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/BackendBuilder.java b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/BackendBuilder.java new file mode 100644 index 0000000000..74b0af36f8 --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/BackendBuilder.java @@ -0,0 +1,25 @@ +/* + * 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.polaris.persistence.nosql.quarkus.backend; + +import org.apache.polaris.persistence.nosql.api.backend.Backend; + +interface BackendBuilder { + Backend buildBackend(); +} diff --git a/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/BackendProvider.java b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/BackendProvider.java new file mode 100644 index 0000000000..ee19e2ff34 --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/BackendProvider.java @@ -0,0 +1,105 @@ +/* + * 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.polaris.persistence.nosql.quarkus.backend; + +import static java.lang.String.format; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Any; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.inject.Produces; +import java.util.Optional; +import org.apache.polaris.persistence.nosql.api.backend.Backend; +import org.apache.polaris.persistence.nosql.api.backend.BackendConfiguration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +class BackendProvider { + private static final Logger LOGGER = LoggerFactory.getLogger(BackendProvider.class); + + @Produces + @ApplicationScoped + @NotObserved + Backend backend( + BackendConfiguration backendConfiguration, @Any Instance backendBuilders) { + + var backendName = + backendConfiguration + .type() + .orElseThrow( + () -> + new IllegalStateException( + "Mandatory configuration option polaris.persistence.backend.type is missing!")); + + var backendBuilder = backendBuilders.select(BackendType.Literal.of(backendName)); + if (!backendBuilder.isResolvable()) { + throw new IllegalStateException( + format( + "Backend '%s' provided in configuration polaris.persistence.backend.type is not available. Available backends: %s", + backendName, + backendBuilders + .handlesStream() + .map( + h -> + h.getBean().getQualifiers().stream() + .filter(q -> q instanceof BackendType) + .map(BackendType.class::cast) + .findFirst() + .map(BackendType::value)) + .filter(Optional::isPresent) + .map(Optional::get) + .toList())); + } + if (backendBuilder.isAmbiguous()) { + throw new IllegalStateException( + format( + "Multiple implementations match the backend name '%s' provided in configuration polaris.persistence.backend.type is not available. All available backends: %s", + backendName, + backendBuilders + .handlesStream() + .map( + h -> + h.getBean().getQualifiers().stream() + .filter(q -> q instanceof BackendType) + .map(BackendType.class::cast) + .findFirst() + .map(BackendType::value)) + .filter(Optional::isPresent) + .map(Optional::get) + .toList())); + } + var builder = backendBuilder.get(); + + var backend = builder.buildBackend(); + try { + var setupSchemaResult = backend.setupSchema().orElse(""); + LOGGER.info("Opened new persistence backend '{}' {}", backend.type(), setupSchemaResult); + + return builder.buildBackend(); + } catch (Exception e) { + try { + backend.close(); + } catch (Exception e2) { + e.addSuppressed(e2); + } + throw e; + } + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/BackendType.java b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/BackendType.java new file mode 100644 index 0000000000..1a675cddae --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/BackendType.java @@ -0,0 +1,59 @@ +/* + * 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.polaris.persistence.nosql.quarkus.backend; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import jakarta.enterprise.util.AnnotationLiteral; +import jakarta.inject.Qualifier; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Target({TYPE, METHOD, PARAMETER, FIELD}) +@Retention(RUNTIME) +@Documented +@Qualifier +public @interface BackendType { + /** Gets the store type. */ + String value(); + + /** Supports inline instantiation of the {@link BackendType} qualifier. */ + final class Literal extends AnnotationLiteral implements BackendType { + + private final String value; + + private Literal(String value) { + this.value = value; + } + + public static Literal of(String value) { + return new Literal(value); + } + + @Override + public String value() { + return value; + } + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/InMemoryBackendBuilder.java b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/InMemoryBackendBuilder.java new file mode 100644 index 0000000000..5a3ea9626b --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/InMemoryBackendBuilder.java @@ -0,0 +1,34 @@ +/* + * 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.polaris.persistence.nosql.quarkus.backend; + +import jakarta.enterprise.context.ApplicationScoped; +import org.apache.polaris.persistence.nosql.api.backend.Backend; +import org.apache.polaris.persistence.nosql.inmemory.InMemoryBackendFactory; +import org.apache.polaris.persistence.nosql.inmemory.InMemoryConfiguration; + +@BackendType(InMemoryBackendFactory.NAME) +@ApplicationScoped +class InMemoryBackendBuilder implements BackendBuilder { + @Override + public Backend buildBackend() { + var factory = new InMemoryBackendFactory(); + return factory.buildBackend(factory.buildConfiguration(new InMemoryConfiguration() {})); + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/MongoDbBackendBuilder.java b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/MongoDbBackendBuilder.java new file mode 100644 index 0000000000..9a3b7615b7 --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/MongoDbBackendBuilder.java @@ -0,0 +1,49 @@ +/* + * 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.polaris.persistence.nosql.quarkus.backend; + +import com.mongodb.client.MongoClient; +import io.quarkus.arc.Arc; +import io.quarkus.mongodb.runtime.MongoClientBeanUtil; +import io.quarkus.mongodb.runtime.MongoClients; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Inject; +import org.apache.polaris.persistence.nosql.api.backend.Backend; +import org.apache.polaris.persistence.nosql.mongodb.MongoDbBackendConfig; +import org.apache.polaris.persistence.nosql.mongodb.MongoDbBackendFactory; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@BackendType(MongoDbBackendFactory.NAME) +@Dependent +class MongoDbBackendBuilder implements BackendBuilder { + @Inject + @ConfigProperty(name = "quarkus.mongodb.database", defaultValue = "polaris") + String databaseName; + + @Override + public Backend buildBackend() { + MongoClients mongoClients = Arc.container().instance(MongoClients.class).get(); + MongoClient client = + mongoClients.createMongoClient(MongoClientBeanUtil.DEFAULT_MONGOCLIENT_NAME); + + var config = new MongoDbBackendConfig(databaseName, client, true, false); + + return new MongoDbBackendFactory().buildBackend(config); + } +} diff --git a/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/NotObserved.java b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/NotObserved.java new file mode 100644 index 0000000000..adcc92b52d --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/NotObserved.java @@ -0,0 +1,36 @@ +/* + * 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.polaris.persistence.nosql.quarkus.backend; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import jakarta.inject.Qualifier; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Target({TYPE, METHOD, PARAMETER, FIELD}) +@Retention(RUNTIME) +@Documented +@Qualifier +public @interface NotObserved {} diff --git a/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/ObservingBackend.java b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/ObservingBackend.java new file mode 100644 index 0000000000..5af1ae484e --- /dev/null +++ b/persistence/nosql/persistence/cdi/quarkus/src/main/java/org/apache/polaris/persistence/nosql/quarkus/backend/ObservingBackend.java @@ -0,0 +1,263 @@ +/* + * 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.polaris.persistence.nosql.quarkus.backend; + +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.config.MeterFilter; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Default; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import org.apache.polaris.ids.api.IdGenerator; +import org.apache.polaris.ids.api.MonotonicClock; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.PersistenceParams; +import org.apache.polaris.persistence.nosql.api.backend.Backend; +import org.apache.polaris.persistence.nosql.api.backend.FetchedObj; +import org.apache.polaris.persistence.nosql.api.backend.PersistId; +import org.apache.polaris.persistence.nosql.api.backend.WriteObj; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.ref.Reference; + +/** Provides telemetry and tracing for all persistence backend operations. */ +@ApplicationScoped +@Default +public class ObservingBackend implements Backend { + public static final String TELEMETRY_PREFIX = "polaris.persistence"; + + private final Backend backend; + + public ObservingBackend(@NotObserved Backend backend) { + this.backend = backend; + } + + @Nonnull + @Override + public String type() { + return backend.type(); + } + + @Nonnull + @Override + public Persistence newPersistence( + Function backendWrapper, + @Nonnull PersistenceParams persistenceParams, + String realmId, + MonotonicClock monotonicClock, + IdGenerator idGenerator) { + return backend.newPersistence( + backendWrapper, persistenceParams, realmId, monotonicClock, idGenerator); + } + + @Override + public boolean supportsRealmDeletion() { + return backend.supportsRealmDeletion(); + } + + @Override + public void close() throws Exception { + backend.close(); + } + + @WithSpan(TELEMETRY_PREFIX + ".setupSchema") + @Counted(TELEMETRY_PREFIX + ".setupSchema") + @Timed(value = TELEMETRY_PREFIX + ".setupSchema", histogram = true) + @Override + public Optional setupSchema() { + return backend.setupSchema(); + } + + @WithSpan(TELEMETRY_PREFIX + ".deleteRealms") + @Counted(TELEMETRY_PREFIX + ".deleteRealms") + @Timed(value = TELEMETRY_PREFIX + ".deleteRealms", histogram = true) + @Override + public void deleteRealms(@SpanAttribute("realms") Set realmIds) { + backend.deleteRealms(realmIds); + } + + @WithSpan(TELEMETRY_PREFIX + ".batchDeleteRefs") + @Counted(TELEMETRY_PREFIX + ".batchDeleteRefs") + @Timed(value = TELEMETRY_PREFIX + ".batchDeleteRefs", histogram = true) + @Override + public void batchDeleteRefs(Map> realmRefs) { + backend.batchDeleteRefs(realmRefs); + } + + @WithSpan(TELEMETRY_PREFIX + ".batchDeleteObjs") + @Counted(TELEMETRY_PREFIX + ".batchDeleteObjs") + @Timed(value = TELEMETRY_PREFIX + ".batchDeleteObjs", histogram = true) + @Override + public void batchDeleteObjs(Map> realmObjs) { + backend.batchDeleteObjs(realmObjs); + } + + @WithSpan(TELEMETRY_PREFIX + ".scanBackend") + @Counted(TELEMETRY_PREFIX + ".scanBackend") + @Timed(value = TELEMETRY_PREFIX + ".scanBackend", histogram = true, longTask = true) + @Override + public void scanBackend( + @Nonnull ReferenceScanCallback referenceConsumer, @Nonnull ObjScanCallback objConsumer) { + backend.scanBackend(referenceConsumer, objConsumer); + } + + @WithSpan(TELEMETRY_PREFIX + ".createReference") + @Counted(TELEMETRY_PREFIX + ".createReference") + @Timed(value = TELEMETRY_PREFIX + ".createReference", histogram = true) + @Override + public boolean createReference( + @SpanAttribute("realm-id") @Nonnull String realmId, @Nonnull Reference newRef) { + return backend.createReference(realmId, newRef); + } + + @WithSpan(TELEMETRY_PREFIX + ".createReferencesSilent") + @Counted(TELEMETRY_PREFIX + ".createReferencesSilent") + @Timed(value = TELEMETRY_PREFIX + ".createReferencesSilent", histogram = true) + @Override + public void createReferences( + @SpanAttribute("realm-id") @Nonnull String realmId, @Nonnull List newRefs) { + backend.createReferences(realmId, newRefs); + } + + @WithSpan(TELEMETRY_PREFIX + ".updateReference") + @Counted(TELEMETRY_PREFIX + ".updateReference") + @Timed(value = TELEMETRY_PREFIX + ".updateReference", histogram = true) + @Override + public boolean updateReference( + @SpanAttribute("realm-id") @Nonnull String realmId, + @Nonnull Reference updatedRef, + @Nonnull Optional expectedPointer) { + return backend.updateReference(realmId, updatedRef, expectedPointer); + } + + @WithSpan(TELEMETRY_PREFIX + ".fetchReference") + @Counted(TELEMETRY_PREFIX + ".fetchReference") + @Timed(value = TELEMETRY_PREFIX + ".fetchReference", histogram = true) + @Nonnull + @Override + public Reference fetchReference( + @SpanAttribute("realm-id") @Nonnull String realmId, @Nonnull String name) { + return backend.fetchReference(realmId, name); + } + + @WithSpan(TELEMETRY_PREFIX + ".fetch") + @Counted(TELEMETRY_PREFIX + ".fetch") + @Timed(value = TELEMETRY_PREFIX + ".fetch", histogram = true) + @Nonnull + @Override + public Map fetch( + @SpanAttribute("realm-id") @Nonnull String realmId, @Nonnull Set ids) { + return backend.fetch(realmId, ids); + } + + @WithSpan(TELEMETRY_PREFIX + ".write") + @Counted(TELEMETRY_PREFIX + ".write") + @Timed(value = TELEMETRY_PREFIX + ".write", histogram = true) + @Override + public void write( + @SpanAttribute("realm-id") @Nonnull String realmId, @Nonnull List writes) { + backend.write(realmId, writes); + } + + @WithSpan(TELEMETRY_PREFIX + ".delete") + @Counted(TELEMETRY_PREFIX + ".delete") + @Timed(value = TELEMETRY_PREFIX + ".delete", histogram = true) + @Override + public void delete( + @SpanAttribute("realm-id") @Nonnull String realmId, @Nonnull Set ids) { + backend.delete(realmId, ids); + } + + @WithSpan(TELEMETRY_PREFIX + ".conditionalInsert") + @Counted(TELEMETRY_PREFIX + ".conditionalInsert") + @Timed(value = TELEMETRY_PREFIX + ".conditionalInsert", histogram = true) + @Override + public boolean conditionalInsert( + @SpanAttribute("realm-id") @Nonnull String realmId, + String objTypeId, + @Nonnull PersistId persistId, + long createdAtMicros, + @Nonnull String versionToken, + @Nonnull byte[] serializedValue) { + return backend.conditionalInsert( + realmId, objTypeId, persistId, createdAtMicros, versionToken, serializedValue); + } + + @WithSpan(TELEMETRY_PREFIX + ".conditionalUpdate") + @Counted(TELEMETRY_PREFIX + ".conditionalUpdate") + @Timed(value = TELEMETRY_PREFIX + ".conditionalUpdate", histogram = true) + @Override + public boolean conditionalUpdate( + @SpanAttribute("realm-id") @Nonnull String realmId, + String objTypeId, + @Nonnull PersistId persistId, + long createdAtMicros, + @Nonnull String updateToken, + @Nonnull String expectedToken, + @Nonnull byte[] serializedValue) { + return backend.conditionalUpdate( + realmId, + objTypeId, + persistId, + createdAtMicros, + updateToken, + expectedToken, + serializedValue); + } + + @WithSpan(TELEMETRY_PREFIX + ".conditionalDelete") + @Counted(TELEMETRY_PREFIX + ".conditionalDelete") + @Timed(value = TELEMETRY_PREFIX + ".conditionalDelete", histogram = true) + @Override + public boolean conditionalDelete( + @SpanAttribute("realm-id") @Nonnull String realmId, + @Nonnull PersistId persistId, + @Nonnull String expectedToken) { + return backend.conditionalDelete(realmId, persistId, expectedToken); + } + + @Produces + @Singleton + public MeterFilter renameApplicationMeters() { + return new MeterFilter() { + @Override + @Nonnull + public Meter.Id map(@Nonnull Meter.Id id) { + var tags = id.getTags(); + var tag = Tag.of("class", ObservingBackend.class.getName()); + if (tags.contains(tag)) { + // drop the 'class' tag, but leave the 'method' tag + tags = tags.stream().filter(t -> !"class".equals(t.getKey())).toList(); + return id.replaceTags(tags); + } + return id; + } + }; + } +} diff --git a/persistence/nosql/persistence/cdi/weld/build.gradle.kts b/persistence/nosql/persistence/cdi/weld/build.gradle.kts new file mode 100644 index 0000000000..4edf6ffe76 --- /dev/null +++ b/persistence/nosql/persistence/cdi/weld/build.gradle.kts @@ -0,0 +1,74 @@ +/* + * 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris NoSQL persistence, providers for CDI/Weld." + +dependencies { + implementation(project(":polaris-persistence-nosql-cdi-common")) + implementation(project(":polaris-persistence-nosql-api")) + runtimeOnly(project(":polaris-nodes-impl")) + runtimeOnly(project(":polaris-nodes-store-nosql")) + runtimeOnly(project(":polaris-persistence-nosql-realms-impl")) + runtimeOnly(project(":polaris-persistence-nosql-realms-store-nosql")) + + compileOnly(platform(libs.jackson.bom)) + compileOnly("com.fasterxml.jackson.core:jackson-annotations") + compileOnly("com.fasterxml.jackson.core:jackson-databind") + + compileOnly(libs.smallrye.config.core) + compileOnly(platform(libs.quarkus.bom)) + compileOnly("io.quarkus:quarkus-core") + + implementation(libs.guava) + implementation(libs.slf4j.api) + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + + testFixturesApi(project(":polaris-persistence-nosql-api")) + testFixturesApi(project(":polaris-persistence-nosql-realms-api")) + testFixturesApi(platform(libs.jackson.bom)) + testFixturesApi("com.fasterxml.jackson.dataformat:jackson-dataformat-smile") + testFixturesApi(project(":polaris-persistence-nosql-inmemory")) + testFixturesApi(testFixtures(project(":polaris-persistence-nosql-inmemory"))) + testFixturesImplementation(project(":polaris-persistence-nosql-cdi-common")) + testFixturesImplementation(project(":polaris-async-api")) + testFixturesRuntimeOnly(project(":polaris-async-java")) + testFixturesApi(libs.jakarta.inject.api) + testFixturesApi(libs.jakarta.enterprise.cdi.api) + testFixturesApi(project(":polaris-idgen-api")) + testFixturesApi(project(":polaris-nodes-api")) + testFixturesRuntimeOnly(libs.smallrye.config.core) + + testImplementation(libs.weld.se.core) + testImplementation(libs.weld.junit5) + testRuntimeOnly(libs.smallrye.jandex) +} + +tasks.withType { isFailOnError = false } diff --git a/persistence/nosql/persistence/cdi/weld/src/main/java/org/apache/polaris/persistence/nosql/weld/BackendProvider.java b/persistence/nosql/persistence/cdi/weld/src/main/java/org/apache/polaris/persistence/nosql/weld/BackendProvider.java new file mode 100644 index 0000000000..7e3d62792e --- /dev/null +++ b/persistence/nosql/persistence/cdi/weld/src/main/java/org/apache/polaris/persistence/nosql/weld/BackendProvider.java @@ -0,0 +1,85 @@ +/* + * 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.polaris.persistence.nosql.weld; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.inject.Produces; +import java.util.stream.Collectors; +import org.apache.polaris.persistence.nosql.api.backend.Backend; +import org.apache.polaris.persistence.nosql.api.backend.BackendConfiguration; +import org.apache.polaris.persistence.nosql.api.backend.BackendFactory; +import org.apache.polaris.persistence.nosql.api.backend.BackendLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +class BackendProvider { + private static final Logger LOGGER = LoggerFactory.getLogger(BackendProvider.class); + + @Produces + @ApplicationScoped + Backend backend( + BackendConfiguration backendConfiguration, Instance backendSpecificConfigs) { + + var factory = + backendConfiguration + .type() + .map(BackendLoader::findFactoryByName) + .map( + f -> { + @SuppressWarnings("unchecked") + var r = (BackendFactory) f; + return r; + }) + .orElseGet( + () -> { + try { + @SuppressWarnings("unchecked") + var r = (BackendFactory) BackendLoader.findFactory(x -> true); + return r; + } catch (IllegalStateException e) { + throw new RuntimeException( + "Backend factory type is configured using the configuration option polaris.persistence.backend.type - available are: " + + BackendLoader.availableFactories() + .map(BackendFactory::name) + .collect(Collectors.joining(", ")), + e); + } + }); + var configType = factory.configurationInterface(); + var config = backendSpecificConfigs.select(configType).get(); + var runtimeConfig = factory.buildConfiguration(config); + + var backend = factory.buildBackend(runtimeConfig); + try { + var setupSchemaResult = backend.setupSchema().orElse(""); + LOGGER.info("Opened new persistence backend '{}' {}", backend.type(), setupSchemaResult); + + return backend; + } catch (Exception e) { + try { + backend.close(); + } catch (Exception e2) { + e.addSuppressed(e2); + } + throw e; + } + } +} diff --git a/persistence/nosql/persistence/cdi/weld/src/main/resources/META-INF/beans.xml b/persistence/nosql/persistence/cdi/weld/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/persistence/cdi/weld/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/persistence/cdi/weld/src/test/java/org/apache/polaris/persistence/nosql/weld/TestProviders.java b/persistence/nosql/persistence/cdi/weld/src/test/java/org/apache/polaris/persistence/nosql/weld/TestProviders.java new file mode 100644 index 0000000000..b3bac334f3 --- /dev/null +++ b/persistence/nosql/persistence/cdi/weld/src/test/java/org/apache/polaris/persistence/nosql/weld/TestProviders.java @@ -0,0 +1,73 @@ +/* + * 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.polaris.persistence.nosql.weld; + +import static org.apache.polaris.persistence.nosql.api.Realms.SYSTEM_REALM_ID; + +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.RealmPersistenceFactory; +import org.apache.polaris.persistence.nosql.api.SystemPersistence; +import org.apache.polaris.persistence.nosql.api.backend.Backend; +import org.apache.polaris.persistence.nosql.realms.api.RealmManagement; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.jboss.weld.junit5.EnableWeld; +import org.jboss.weld.junit5.WeldInitiator; +import org.jboss.weld.junit5.WeldSetup; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith({SoftAssertionsExtension.class}) +@EnableWeld +public class TestProviders { + @InjectSoftAssertions SoftAssertions soft; + + @WeldSetup WeldInitiator weld = WeldInitiator.performDefaultDiscovery(); + + @Test + public void checkProviders() { + var backend = weld.select(Backend.class).get(); + soft.assertThat(backend.type()).isEqualTo("InMemory"); + + var realmManagement = weld.select(RealmManagement.class).get(); + soft.assertThat(realmManagement.get("fooBar")).isEmpty(); + + var systemPersistence = weld.select(Persistence.class, new SystemPersistence.Literal()).get(); + soft.assertThat(systemPersistence.realmId()).isEqualTo(SYSTEM_REALM_ID); + + var requestScopedRunner = weld.select(RequestScopedRunner.class).get(); + requestScopedRunner.runWithRequestContext( + () -> { + var builder1 = weld.select(RealmPersistenceFactory.class).get(); + var persistence1 = builder1.newBuilder().realmId("my-realm").build(); + soft.assertThat(persistence1.realmId()).isEqualTo("my-realm"); + + var builder2 = weld.select(RealmPersistenceFactory.class).get(); + var persistence2 = builder2.newBuilder().realmId("other-realm").build(); + soft.assertThat(persistence2.realmId()).isEqualTo("other-realm"); + + // Trigger IdGenerator "activation" + persistence1.generateId(); + + // Trigger IdGenerator "activation" + persistence2.generateId(); + }); + } +} diff --git a/persistence/nosql/persistence/cdi/weld/src/test/resources/META-INF/beans.xml b/persistence/nosql/persistence/cdi/weld/src/test/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/persistence/cdi/weld/src/test/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/persistence/cdi/weld/src/test/resources/logback-test.xml b/persistence/nosql/persistence/cdi/weld/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..aafa701dc4 --- /dev/null +++ b/persistence/nosql/persistence/cdi/weld/src/test/resources/logback-test.xml @@ -0,0 +1,30 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/persistence/nosql/persistence/cdi/weld/src/test/resources/weld.properties b/persistence/nosql/persistence/cdi/weld/src/test/resources/weld.properties new file mode 100644 index 0000000000..c26169e0e1 --- /dev/null +++ b/persistence/nosql/persistence/cdi/weld/src/test/resources/weld.properties @@ -0,0 +1,21 @@ +# +# 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. +# + +# See https://bugs.openjdk.org/browse/JDK-8349545 +org.jboss.weld.bootstrap.concurrentDeployment=false diff --git a/persistence/nosql/persistence/cdi/weld/src/testFixtures/java/org/apache/polaris/persistence/nosql/weld/CdiTestingProviders.java b/persistence/nosql/persistence/cdi/weld/src/testFixtures/java/org/apache/polaris/persistence/nosql/weld/CdiTestingProviders.java new file mode 100644 index 0000000000..28511a5524 --- /dev/null +++ b/persistence/nosql/persistence/cdi/weld/src/testFixtures/java/org/apache/polaris/persistence/nosql/weld/CdiTestingProviders.java @@ -0,0 +1,97 @@ +/* + * 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.polaris.persistence.nosql.weld; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import org.apache.polaris.ids.api.IdGeneratorSpec; +import org.apache.polaris.ids.api.MonotonicClock; +import org.apache.polaris.misc.types.memorysize.MemorySize; +import org.apache.polaris.nosql.async.AsyncConfiguration; +import org.apache.polaris.persistence.nosql.api.PersistenceParams; +import org.apache.polaris.persistence.nosql.api.backend.BackendConfiguration; +import org.apache.polaris.persistence.nosql.api.cache.CacheConfig; +import org.apache.polaris.persistence.nosql.api.cache.CacheSizing; +import org.apache.polaris.persistence.nosql.inmemory.InMemoryConfiguration; +import org.apache.polaris.persistence.nosql.nodeids.api.NodeManagementConfig; + +@ApplicationScoped +public class CdiTestingProviders { + + @Produces + @ApplicationScoped + AsyncConfiguration asyncConfiguration() { + return AsyncConfiguration.builder().build(); + } + + @Produces + @ApplicationScoped + BackendConfiguration backendConfiguration() { + return BackendConfiguration.BuildableBackendConfiguration.builder().type("InMemory").build(); + } + + @Produces + @ApplicationScoped + InMemoryConfiguration inMemoryConfiguration() { + return new InMemoryConfiguration() {}; + } + + @SuppressWarnings("CdiInjectionPointsInspection") + @Produces + @ApplicationScoped + CacheConfig cacheConfig(MonotonicClock monotonicClock) { + return CacheConfig.BuildableCacheConfig.builder() + .sizing(CacheSizing.builder().fixedSize(MemorySize.ofMega(32)).build()) + .clockNanos(monotonicClock::nanoTime) + .build(); + } + + @Produces + @ApplicationScoped + NodeManagementConfig nodeManagementConfig() { + return NodeManagementConfig.BuildableNodeManagementConfig.builder() + .idGeneratorSpec(IdGeneratorSpec.BuildableIdGeneratorSpec.builder().build()) + .build(); + } + + @Produces + @ApplicationScoped + PersistenceParams persistenceBaseConfig() { + return PersistenceParams.BuildablePersistenceParams.builder().build(); + } + + private ScheduledExecutorService executorService; + + @PostConstruct + void initScheduler() { + executorService = Executors.newScheduledThreadPool(2); + } + + @PreDestroy + void stopScheduler() { + // "Forget" tasks scheduled in the future + executorService.shutdownNow(); + // Properly close + executorService.close(); + } +} diff --git a/persistence/nosql/persistence/cdi/weld/src/testFixtures/java/org/apache/polaris/persistence/nosql/weld/RequestScopedRunner.java b/persistence/nosql/persistence/cdi/weld/src/testFixtures/java/org/apache/polaris/persistence/nosql/weld/RequestScopedRunner.java new file mode 100644 index 0000000000..41f21cde68 --- /dev/null +++ b/persistence/nosql/persistence/cdi/weld/src/testFixtures/java/org/apache/polaris/persistence/nosql/weld/RequestScopedRunner.java @@ -0,0 +1,37 @@ +/* + * 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.polaris.persistence.nosql.weld; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.control.ActivateRequestContext; +import java.util.concurrent.Callable; + +/** Helper for tests to run code in the CDI request scope. */ +@ApplicationScoped +public class RequestScopedRunner { + @ActivateRequestContext + public void runWithRequestContext(Runnable r) { + r.run(); + } + + @ActivateRequestContext + public R callWithRequestContext(Callable r) throws Exception { + return r.call(); + } +} diff --git a/persistence/nosql/persistence/cdi/weld/src/testFixtures/resources/META-INF/beans.xml b/persistence/nosql/persistence/cdi/weld/src/testFixtures/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/persistence/cdi/weld/src/testFixtures/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/persistence/correctness/src/correctnessInMemoryTest/resources/logback-test.xml b/persistence/nosql/persistence/correctness/src/correctnessInMemoryTest/resources/logback-test.xml new file mode 100644 index 0000000000..4a4d9a629d --- /dev/null +++ b/persistence/nosql/persistence/correctness/src/correctnessInMemoryTest/resources/logback-test.xml @@ -0,0 +1,32 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/persistence/nosql/persistence/correctness/src/correctnessManualTest/resources/logback-test.xml b/persistence/nosql/persistence/correctness/src/correctnessManualTest/resources/logback-test.xml new file mode 100644 index 0000000000..4a4d9a629d --- /dev/null +++ b/persistence/nosql/persistence/correctness/src/correctnessManualTest/resources/logback-test.xml @@ -0,0 +1,32 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/persistence/nosql/persistence/correctness/src/correctnessMongoDbTest/resources/logback-test.xml b/persistence/nosql/persistence/correctness/src/correctnessMongoDbTest/resources/logback-test.xml new file mode 100644 index 0000000000..aafa701dc4 --- /dev/null +++ b/persistence/nosql/persistence/correctness/src/correctnessMongoDbTest/resources/logback-test.xml @@ -0,0 +1,30 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/persistence/nosql/persistence/maintenance/impl/build.gradle.kts b/persistence/nosql/persistence/maintenance/impl/build.gradle.kts new file mode 100644 index 0000000000..058a0483d2 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/build.gradle.kts @@ -0,0 +1,83 @@ +/* + * 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris NoSQL persistence maintenance - service implementation" + +dependencies { + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-persistence-nosql-maintenance-api")) + implementation(project(":polaris-persistence-nosql-maintenance-spi")) + implementation(project(":polaris-persistence-nosql-realms-api")) + implementation(project(":polaris-idgen-api")) + runtimeOnly(project(":polaris-persistence-nosql-realms-impl")) + runtimeOnly(project(":polaris-persistence-nosql-realms-store-nosql")) + + implementation(libs.guava) + implementation(libs.slf4j.api) + + compileOnly(platform(libs.jackson.bom)) + compileOnly("com.fasterxml.jackson.core:jackson-annotations") + compileOnly("com.fasterxml.jackson.core:jackson-databind") + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + + testFixturesApi(project(":polaris-persistence-nosql-api")) + testFixturesApi(project(":polaris-persistence-nosql-maintenance-api")) + testFixturesApi(project(":polaris-persistence-nosql-maintenance-spi")) + testFixturesApi(project(":polaris-persistence-nosql-testextension")) + + testFixturesCompileOnly(project(":polaris-immutables")) + testFixturesAnnotationProcessor(project(":polaris-immutables", configuration = "processor")) + + testFixturesCompileOnly(platform(libs.jackson.bom)) + testFixturesCompileOnly("com.fasterxml.jackson.core:jackson-annotations") + testFixturesCompileOnly("com.fasterxml.jackson.core:jackson-databind") + + testFixturesImplementation(libs.jakarta.annotation.api) + testFixturesImplementation(libs.jakarta.validation.api) + testFixturesCompileOnly(libs.jakarta.enterprise.cdi.api) + + testCompileOnly(platform(libs.jackson.bom)) + testCompileOnly("com.fasterxml.jackson.core:jackson-annotations") + testCompileOnly("com.fasterxml.jackson.core:jackson-databind") + + testRuntimeOnly(libs.logback.classic) + + testImplementation(project(":polaris-idgen-mocks")) + testRuntimeOnly(testFixtures(project(":polaris-persistence-nosql-cdi-weld"))) + testImplementation(libs.weld.se.core) + testImplementation(libs.weld.junit5) + testRuntimeOnly(libs.smallrye.jandex) + + testRuntimeOnly(project(":polaris-persistence-nosql-realms-impl")) + testRuntimeOnly(project(":polaris-persistence-nosql-realms-store-nosql")) + testRuntimeOnly(project(":polaris-persistence-nosql-inmemory")) + testImplementation(testFixtures(project(":polaris-persistence-nosql-inmemory"))) +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/AbstractScanItemStatsCollector.java b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/AbstractScanItemStatsCollector.java new file mode 100644 index 0000000000..5fde8273bf --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/AbstractScanItemStatsCollector.java @@ -0,0 +1,113 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import jakarta.annotation.Nonnull; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceRunInformation.MaintenanceStats; + +abstract class AbstractScanItemStatsCollector implements ScanItemCallback { + final StatsHolder stats = new StatsHolder(); + + /** Collect maintenance-run stats for objects per realm. */ + static final class ScanRefStatsCollector extends AbstractScanItemStatsCollector { + final Map perRealm = new HashMap<>(); + + /** Handles the maintenance-run outcome for a reference in a realm. */ + @Override + public void itemOutcome( + @Nonnull String realm, @Nonnull String ref, @Nonnull ScanItemOutcome outcome) { + stats.add(outcome); + perRealm.computeIfAbsent(realm, realmId -> new StatsHolder()).add(outcome); + } + + /** Retrieve maintenance-run reference stats per realm. */ + Map toRealmObjTypeStatsMap() { + return perRealm.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, r -> r.getValue().toMaintenanceStats())); + } + } + + /** + * Collect maintenance-run stats for objects per realm and {@linkplain + * org.apache.polaris.persistence.nosql.api.obj.ObjType object type}. + */ + static final class ScanObjStatsCollector extends AbstractScanItemStatsCollector { + final Map> perRealmAndObjType = new HashMap<>(); + + /** Handles the maintenance-run outcome for an object in a realm. */ + @Override + public void itemOutcome( + @Nonnull String realm, @Nonnull ObjRef id, @Nonnull ScanItemOutcome outcome) { + stats.add(outcome); + perRealmAndObjType + .computeIfAbsent(realm, realmId -> new HashMap<>()) + .computeIfAbsent(id.type(), objType -> new StatsHolder()) + .add(outcome); + } + + /** + * Retrieve maintenance-run reference stats per realm and {@linkplain + * org.apache.polaris.persistence.nosql.api.obj.ObjType object type}. + */ + Map> toRealmObjTypeStatsMap() { + return perRealmAndObjType.entrySet().stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + r -> + r.getValue().entrySet().stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, ot -> ot.getValue().toMaintenanceStats())))); + } + } + + /** Maintenance stats holder. */ + static final class StatsHolder { + long scanned; + long newer; + long retained; + long purged; + + /** Consume the outcome for a reference or object-type. */ + void add(ScanItemOutcome outcome) { + scanned++; + switch (outcome) { + case REALM_PURGE, PURGED -> purged++; + case TOO_NEW_RETAINED -> newer++; + case RETAINED, UNHANDLED_RETAINED -> retained++; + default -> throw new IllegalStateException("Unknown outcome " + outcome); + } + } + + /** Produce the serializable-stats container. */ + MaintenanceStats toMaintenanceStats() { + return MaintenanceStats.builder() + .scanned(scanned) + .newer(newer) + .retained(retained) + .purged(purged) + .build(); + } + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/AllRetained.java b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/AllRetained.java new file mode 100644 index 0000000000..f49cf06cb9 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/AllRetained.java @@ -0,0 +1,105 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import static java.util.Map.entry; + +import com.google.common.hash.BloomFilter; +import com.google.common.hash.PrimitiveSink; +import jakarta.annotation.Nonnull; +import java.util.Map; +import java.util.function.Predicate; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.maintenance.impl.ScanHandler.RetainCheck; +import org.jspecify.annotations.NonNull; + +/** + * Collects reference names and objects to retain. + * + *

The implementation uses bloom-filters to limit the heap usage when a huge number of + * references/objects is being used. + */ +@SuppressWarnings("UnstableApiUsage") +final class AllRetained { + + /** + * Some "salt" to make the bloom filters non-deterministic, in case there are false-positives, to + * reduce the number of false positives over time. + */ + private final int salt; + + // @NonNull is the jspecify variant, which allows type-usage. + // Jakarta's @Nonnull does not allow type-usage. + private final BloomFilter> refsFilter; + private final BloomFilter> objsFilter; + private long refAdds; + private long objAdds; + + AllRetained(long expectedReferenceCount, long expectedObjCount, double fpp, int salt) { + this.salt = salt; + this.refsFilter = BloomFilter.create(this::refFunnel, expectedReferenceCount, fpp); + this.objsFilter = BloomFilter.create(this::objFunnel, expectedObjCount, fpp); + } + + private void refFunnel(Map.Entry realmRef, @Nonnull PrimitiveSink primitiveSink) { + primitiveSink.putInt(salt); + primitiveSink.putUnencodedChars(realmRef.getKey()); + primitiveSink.putUnencodedChars(realmRef.getValue()); + } + + private void objFunnel(Map.Entry realmObj, @Nonnull PrimitiveSink primitiveSink) { + primitiveSink.putInt(salt); + primitiveSink.putUnencodedChars(realmObj.getKey()); + var id = realmObj.getValue(); + primitiveSink.putLong(id); + } + + void addRetainedRef(String realm, String ref) { + refsFilter.put(entry(realm, ref)); + refAdds++; + } + + void addRetainedObj(String realm, long id) { + objsFilter.put(entry(realm, id)); + objAdds++; + } + + /** The number of {@link #addRetainedRef(String, String)} invocations. */ + long refAdds() { + return refAdds; + } + + /** The number of {@link #addRetainedObj(String, long)} invocations. */ + long objAdds() { + return objAdds; + } + + boolean withinExpectedFpp(double expectedFpp) { + return refsFilter.expectedFpp() < expectedFpp && objsFilter.expectedFpp() < expectedFpp; + } + + RetainCheck referenceRetainCheck() { + return (realm, ref) -> refsFilter.mightContain(entry(realm, ref)); + } + + RetainCheck objRetainCheck(Predicate objTypeIdPredicate) { + return (realm, id) -> + objTypeIdPredicate.test(id.type()) || objsFilter.mightContain(entry(realm, id.id())); + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceRunObj.java b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceRunObj.java new file mode 100644 index 0000000000..6ce50e348b --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceRunObj.java @@ -0,0 +1,67 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.util.function.LongSupplier; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.Obj; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceRunInformation; + +/** + * Holds information about one maintenance run. + * + *

This object is eventually overwritten with the final result of the maintenance run. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutableMaintenanceRunObj.class) +@JsonDeserialize(as = ImmutableMaintenanceRunObj.class) +public interface MaintenanceRunObj extends Obj { + + ObjType TYPE = new MaintenanceRunObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + MaintenanceRunInformation runInformation(); + + static ImmutableMaintenanceRunObj.Builder builder() { + return ImmutableMaintenanceRunObj.builder(); + } + + final class MaintenanceRunObjType extends AbstractObjType { + public MaintenanceRunObjType() { + super("mtr", "Maintenance Run", MaintenanceRunObj.class); + } + + @Override + public long cachedObjectExpiresAtMicros(Obj obj, LongSupplier clockMicros) { + var mo = (MaintenanceRunObj) obj; + if (mo.runInformation().finished().isPresent()) { + return CACHE_UNLIMITED; + } + return NOT_CACHED; + } + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceRunsObj.java b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceRunsObj.java new file mode 100644 index 0000000000..ca476351d7 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceRunsObj.java @@ -0,0 +1,65 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.BaseCommitObj; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; + +@PolarisImmutable +@JsonSerialize(as = ImmutableMaintenanceRunsObj.class) +@JsonDeserialize(as = ImmutableMaintenanceRunsObj.class) +public interface MaintenanceRunsObj extends BaseCommitObj { + + String MAINTENANCE_RUNS_REF_NAME = "maintenance-runs"; + + ObjType TYPE = new MaintenanceRunsObjType(); + + static ImmutableMaintenanceRunsObj.Builder builder() { + return ImmutableMaintenanceRunsObj.builder(); + } + + /** + * The ID of the object holding the maintenance run information. + * + *

The {@linkplain MaintenanceRunObj#runInformation() maintenance run information} is + * not included in this object, because {@link MaintenanceRunObj} is initially written as + * "currently running" and then updated with the final state of the maintenance run. Updating the + * {@link MaintenanceRunObj} is not great but okay, but updating a {@link BaseCommitObj} is an + * absolute no-go. + */ + ObjRef maintenanceRunId(); + + @Override + default ObjType type() { + return TYPE; + } + + final class MaintenanceRunsObjType extends AbstractObjType { + public MaintenanceRunsObjType() { + super("mtrs", "Maintenance Runs", MaintenanceRunsObj.class); + } + } + + interface Builder extends BaseCommitObj.Builder {} +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceServiceImpl.java b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceServiceImpl.java new file mode 100644 index 0000000000..897156b184 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceServiceImpl.java @@ -0,0 +1,617 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.lang.String.format; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.apache.polaris.persistence.nosql.api.Realms.SYSTEM_REALM_ID; +import static org.apache.polaris.persistence.nosql.api.Realms.SYSTEM_REALM_PREFIX; +import static org.apache.polaris.persistence.nosql.api.backend.PersistId.persistId; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.apache.polaris.persistence.nosql.api.obj.ObjTypes.objTypeById; +import static org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceConfig.DEFAULT_COUNT_FROM_LAST_RUN_MULTIPLIER; +import static org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceConfig.DEFAULT_DELETE_BATCH_SIZE; +import static org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceConfig.DEFAULT_INITIALIZED_FPP; +import static org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceConfig.DEFAULT_MAX_ACCEPTABLE_FPP; +import static org.apache.polaris.persistence.nosql.maintenance.impl.MaintenanceRunsObj.MAINTENANCE_RUNS_REF_NAME; +import static org.apache.polaris.persistence.nosql.realms.api.RealmDefinition.RealmStatus.ACTIVE; +import static org.apache.polaris.persistence.nosql.realms.api.RealmDefinition.RealmStatus.INACTIVE; +import static org.apache.polaris.persistence.nosql.realms.api.RealmDefinition.RealmStatus.PURGED; +import static org.apache.polaris.persistence.nosql.realms.api.RealmDefinition.RealmStatus.PURGING; + +import com.google.common.collect.Streams; +import com.google.common.math.LongMath; +import jakarta.annotation.Nonnull; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.math.RoundingMode; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.polaris.ids.api.MonotonicClock; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.RealmPersistenceFactory; +import org.apache.polaris.persistence.nosql.api.SystemPersistence; +import org.apache.polaris.persistence.nosql.api.backend.Backend; +import org.apache.polaris.persistence.nosql.api.commit.Committer; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjTypes; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceConfig; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceRunInformation; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceRunSpec; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceService; +import org.apache.polaris.persistence.nosql.maintenance.spi.ObjTypeRetainedIdentifier; +import org.apache.polaris.persistence.nosql.maintenance.spi.PerRealmRetainedIdentifier; +import org.apache.polaris.persistence.nosql.realms.api.RealmDefinition; +import org.apache.polaris.persistence.nosql.realms.api.RealmExpectedStateMismatchException; +import org.apache.polaris.persistence.nosql.realms.api.RealmManagement; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Retrieve an instance of this class by adding the {@code + * polaris-persistence-nosql-maintenance-impl} artifact to the runtime class path and then use CDI + * via {@code @Inject MaintenanceService} to access it. + */ +@ApplicationScoped +class MaintenanceServiceImpl implements MaintenanceService { + private static final Logger LOGGER = LoggerFactory.getLogger(MaintenanceServiceImpl.class); + + static final int MIN_GRACE_TIME_MINUTES = 5; + + private static final long MIN_GRACE_TIME_MICROS = + TimeUnit.MINUTES.toMicros(MIN_GRACE_TIME_MINUTES); + + private final Backend backend; + private final Persistence systemPersistence; + private final Committer committer; + private final RealmPersistenceFactory realmPersistenceFactory; + private final RealmManagement realmManagement; + private final List perRealmRetainedIdentifiers; + private final Map> objTypeRetainedIdentifiers; + private final MaintenanceConfig maintenanceConfig; + private final MonotonicClock monotonicClock; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + MaintenanceServiceImpl( + Backend backend, + @SystemPersistence Persistence systemPersistence, + RealmPersistenceFactory realmPersistenceFactory, + RealmManagement realmManagement, + Instance realmRetainedIdentifiers, + Instance objTypeRetainedIdentifiers, + MaintenanceConfig maintenanceConfig, + MonotonicClock monotonicClock) { + checkArgument( + SYSTEM_REALM_ID.equals(systemPersistence.realmId()), + "Realms management must happen in the %s realm", + SYSTEM_REALM_ID); + + this.backend = backend; + this.systemPersistence = systemPersistence; + this.realmManagement = realmManagement; + this.realmPersistenceFactory = realmPersistenceFactory; + this.maintenanceConfig = maintenanceConfig; + this.monotonicClock = monotonicClock; + + this.perRealmRetainedIdentifiers = realmRetainedIdentifiers.stream().toList(); + this.objTypeRetainedIdentifiers = + objTypeRetainedIdentifiers.stream() + .collect(Collectors.groupingBy(oti -> oti.handledObjType().id())); + this.committer = + systemPersistence.createCommitter( + MAINTENANCE_RUNS_REF_NAME, MaintenanceRunsObj.class, MaintenanceRunObj.class); + + maintenanceServiceReport(); + } + + @PostConstruct + void init() { + // Do this in a @PostConstruct method as it involves I/O, which isn't a good thing to do in a + // constructor, especially in CDI + systemPersistence.createReferenceSilent(MAINTENANCE_RUNS_REF_NAME); + } + + @Override + @Nonnull + public List maintenanceRunLog() { + var runIds = + Streams.stream( + systemPersistence + .commits() + .commitLog( + MAINTENANCE_RUNS_REF_NAME, OptionalLong.empty(), MaintenanceRunsObj.class)) + .filter(Objects::nonNull) + .limit(maintenanceConfig.retainedRuns().orElse(MaintenanceConfig.DEFAULT_RETAINED_RUNS)) + .map(MaintenanceRunsObj::maintenanceRunId) + .toArray(ObjRef[]::new); + return Stream.of(systemPersistence.fetchMany(MaintenanceRunObj.class, runIds)) + .filter(Objects::nonNull) + .map(MaintenanceRunObj::runInformation) + .toList(); + } + + @Nonnull + @Override + public MaintenanceRunSpec buildMaintenanceRunSpec() { + try (var realms = realmManagement.list()) { + var specBuilder = MaintenanceRunSpec.builder(); + realms.forEach( + realm -> { + switch (realm.status()) { + case CREATED, INITIALIZING, LOADING, PURGED -> { + // Don't handle these states, those are either final or known to contain + // inconsistent data + } + case PURGING -> specBuilder.addRealmsToPurge(realm.id()); + case ACTIVE, INACTIVE -> specBuilder.addRealmsToProces(realm.id()); + default -> + throw new IllegalStateException( + "Unexpected realm status " + realm.status() + " for realm " + realm.id()); + } + }); + return specBuilder.build(); + } + } + + @Override + @Nonnull + public MaintenanceRunInformation performMaintenance( + @Nonnull MaintenanceRunSpec maintenanceRunSpec) { + LOGGER.info( + "Triggering maintenance run with {} realms to purge and {} realms to process", + maintenanceRunSpec.realmsToPurge().size(), + maintenanceRunSpec.realmsToProcess().size()); + + checkArgument( + maintenanceRunSpec.realmsToPurge().stream() + .noneMatch(maintenanceRunSpec.realmsToProcess()::contains) + && maintenanceRunSpec.realmsToProcess().stream() + .noneMatch(maintenanceRunSpec.realmsToPurge()::contains), + "No realm ID must be included in both the set of realms to process and the set of realms to purge"); + checkArgument( + Stream.concat( + maintenanceRunSpec.realmsToPurge().stream(), + maintenanceRunSpec.realmsToProcess().stream()) + .noneMatch(id -> id.startsWith(SYSTEM_REALM_PREFIX)), + "System realm IDs must not be present in the maintenance run specification"); + + var config = maintenanceConfig; + checkConfig(config); + + // TODO follow-up: some safeguard that checks the run-log for an unfinished run, outside of this + // function! + + var allRetained = constructAllRetained(config); + + var runObj = initMaintenanceRunObj(); + var runInfo = MaintenanceRunInformation.builder().from(runObj.runInformation()); + + var maxCreatedAtMicros = calcMaxCreatedAtMicros(config); + + var description = new StringWriter(); + var descriptionWriter = new PrintWriter(description); + + var info = (MaintenanceRunInformation) null; + try { + try { + var realmsToProcess = processRealms(maintenanceRunSpec, allRetained, descriptionWriter); + var realmsToPurge = purgeRealms(maintenanceRunSpec, descriptionWriter); + + if (maintenanceRunSpec.includeSystemRealm()) { + realmsToProcess.add(SYSTEM_REALM_ID); + identifyAgainstRealm(SYSTEM_REALM_ID, allRetained); + } + + if (!maintenanceRunSpec.realmsToPurge().isEmpty() && backend.supportsRealmDeletion()) { + LOGGER.info( + "Purging realms {} directly against the backend database...", + String.join(", ", maintenanceRunSpec.realmsToPurge())); + backend.deleteRealms(maintenanceRunSpec.realmsToPurge()); + runInfo.purgedRealms(maintenanceRunSpec.realmsToPurge().size()); + } else { + runInfo.purgedRealms(0); + } + + var seenRealmsToPurge = new HashSet(); + var expectFpp = config.maxAcceptableFilterFpp().orElse(DEFAULT_MAX_ACCEPTABLE_FPP); + + var refStats = new AbstractScanItemStatsCollector.ScanRefStatsCollector(); + var objStats = new AbstractScanItemStatsCollector.ScanObjStatsCollector(); + + // Ensures that objects with unknown obj-types do not get purged. + // The assumption here is that if the obj-type is not known, there's also no + // ObjTypeRetainedIdentifier/RealmRetainedIdentifier, which could handle these object types. + // As a follow-up, it might be appropriate to add an advanced configuration option to define + // the object types that shall be purged. + // Even if the obj-type is unknown, to clean up after an extension/plugin that used those + // object-types is no longer being used. + var nonGenericObjTypeIds = ObjTypes.nonGenericObjTypes().keySet(); + var objTypeIdPredicate = + (Predicate) objTypeId -> !nonGenericObjTypeIds.contains(objTypeId); + + var canDelete = allRetained.withinExpectedFpp(expectFpp); + try (var refHandler = + new ScanHandler<>( + "reference", + config.referenceScanRateLimitPerSecond(), + maxCreatedAtMicros, + realmsToProcess, + realmsToPurge, + seenRealmsToPurge::add, + allRetained.referenceRetainCheck(), + config.deleteBatchSize().orElse(DEFAULT_DELETE_BATCH_SIZE), + realmRefs -> { + if (canDelete) { + backend.batchDeleteRefs(realmRefs); + } + }, + refStats); + var objHandler = + new ScanHandler<>( + "object", + config.objectScanRateLimitPerSecond(), + maxCreatedAtMicros, + realmsToProcess, + realmsToPurge, + seenRealmsToPurge::add, + allRetained.objRetainCheck(objTypeIdPredicate), + config.deleteBatchSize().orElse(DEFAULT_DELETE_BATCH_SIZE), + realmObjs -> { + if (canDelete) { + backend.batchDeleteObjs( + realmObjs.entrySet().stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + e -> + e.getValue().stream() + .map(oid -> persistId(oid.id(), oid.numParts())) + .collect(Collectors.toSet())))); + } + }, + objStats)) { + + LOGGER.info("Start scanning backend database, too-new: {} ...", maxCreatedAtMicros); + backend.scanBackend( + refHandler.asReferenceScanCallback(monotonicClock::currentTimeMillis), + objHandler.asObjScanCallback(monotonicClock::currentTimeMillis)); + + LOGGER.info("Finished scanning backend database"); + + runInfo + .referenceStats(refStats.stats.toMaintenanceStats()) + .objStats(objStats.stats.toMaintenanceStats()) + .perRealmPerObjTypeStats(objStats.toRealmObjTypeStatsMap()) + .perRealmReferenceStats(refStats.toRealmObjTypeStatsMap()) + .identifiedObjs(allRetained.objAdds()) + .identifiedReferences(allRetained.refAdds()); + + if (!canDelete) { + var warn = + "Maintenance run finished but did not purge all unreferenced objects, " + + "because the probabilistic filter was not properly sized. " + + "The next maintenance run will be sized according to current run."; + if (!seenRealmsToPurge.isEmpty()) { + warn += + format("\nRealms %s NOT marked as purged.", String.join(", ", seenRealmsToPurge)); + } + descriptionWriter.println(warn); + LOGGER.warn(warn); + } + } + + if (canDelete) { + updateRealmsAsPurged(maintenanceRunSpec, seenRealmsToPurge); + } + + runInfo.success(true); + + LOGGER.info("Maintenance run completed successfully"); + } catch (Exception e) { + LOGGER.info("Maintenance run failed", e); + + runInfo.success(false).statusMessage("Maintenance run did not finish successfully."); + + // Add stack trace as detailed information + descriptionWriter.printf("FAILURE:%n"); + e.printStackTrace(descriptionWriter); + } finally { + descriptionWriter.flush(); + runInfo + .finished(monotonicClock.currentInstant()) + .detailedInformation(description.toString()); + info = runInfo.build(); + } + } finally { + LOGGER.info("Persisting maintenance result {}", info); + systemPersistence.write( + MaintenanceRunObj.builder().from(runObj).runInformation(info).build(), + MaintenanceRunObj.class); + } + + return info; + } + + private void updateRealmsAsPurged( + MaintenanceRunSpec maintenanceRunSpec, HashSet seenRealmsToPurge) { + // Update the realm status of the realms that were specified to be purged as `PURGED` if no + // data for those realms has been seen. + maintenanceRunSpec.realmsToPurge().stream() + .filter(r -> !seenRealmsToPurge.contains(r)) + .map(realmManagement::get) + .filter(Optional::isPresent) + .map(Optional::get) + .forEach( + purgedRealm -> { + try { + realmManagement.update( + purgedRealm, + RealmDefinition.builder().from(purgedRealm).status(PURGED).build()); + } catch (RealmExpectedStateMismatchException e) { + // ignore, do the state transition during the next maintenance run + } + }); + } + + private void maintenanceServiceReport() { + LOGGER.info("Using {} realm retained identifiers", perRealmRetainedIdentifiers.size()); + perRealmRetainedIdentifiers.forEach( + realmRetainedIdentifier -> + LOGGER.info("Realm retained identifier: {}", realmRetainedIdentifier.name())); + LOGGER.info( + "Using {} object type identifiers:", + objTypeRetainedIdentifiers.values().stream().mapToInt(List::size).sum()); + objTypeRetainedIdentifiers.forEach( + (type, idents) -> { + LOGGER.info( + "Using {} identifiers for object type '{}' {}", + idents.size(), + type, + objTypeById(type).name()); + idents.forEach( + objTypeRetainedIdentifier -> + LOGGER.info( + "Object type '{}' identifier: {}", type, objTypeRetainedIdentifier.name())); + }); + } + + private Set processRealms( + MaintenanceRunSpec maintenanceRunSpec, + AllRetained allRetained, + PrintWriter descriptionWriter) { + var realmsToProcess = new HashSet(); + for (var realmId : maintenanceRunSpec.realmsToProcess()) { + var currentRealmStatus = + realmManagement.get(realmId).map(RealmDefinition::status).orElse(ACTIVE); + if ((currentRealmStatus == ACTIVE || currentRealmStatus == INACTIVE) + && identifyAgainstRealm(realmId, allRetained)) { + realmsToProcess.add(realmId); + } + } + maintenanceRunSpec.realmsToProcess().stream() + .filter(r -> !realmsToProcess.contains(r)) + .forEach( + r -> { + var msg = + format( + "No realm retained identifier was able to handle the realm '%s' or the realm is not in status ACTIVE or INACTIVE, no references or objects will be purged from this realm.", + r); + descriptionWriter.println(msg); + LOGGER.warn(msg); + }); + return realmsToProcess; + } + + private Set purgeRealms( + MaintenanceRunSpec maintenanceRunSpec, PrintWriter descriptionWriter) { + var realmsToPurge = new HashSet(); + for (var realmId : maintenanceRunSpec.realmsToPurge()) { + var currentRealmStatus = + realmManagement.get(realmId).map(RealmDefinition::status).orElse(PURGED); + if (currentRealmStatus == PURGING || currentRealmStatus == PURGED) { + realmsToPurge.add(realmId); + } + } + maintenanceRunSpec.realmsToPurge().stream() + .filter(r -> !realmsToPurge.contains(r)) + .forEach( + r -> { + var msg = + format( + "The realm '%s' is not in state PURGING, will therefore not be purged.", r); + descriptionWriter.println(msg); + LOGGER.warn(msg); + }); + return realmsToPurge; + } + + private long calcMaxCreatedAtMicros(MaintenanceConfig effectiveConfig) { + var now = monotonicClock.currentTimeMicros(); + var grace = + effectiveConfig + .createdAtGraceTime() + .map( + d -> { + var micros = SECONDS.toMicros(d.toSeconds()); + micros += TimeUnit.NANOSECONDS.toMicros(d.toNanosPart()); + return Math.max(micros, MIN_GRACE_TIME_MICROS); + }) + .orElse(MIN_GRACE_TIME_MICROS); + return now - grace; + } + + private AllRetained constructAllRetained(MaintenanceConfig effectiveConfig) { + var expectedReferenceCount = + effectiveConfig + .expectedReferenceCount() + .orElse(MaintenanceConfig.DEFAULT_EXPECTED_REFERENCE_COUNT); + var expectedObjCount = + effectiveConfig.expectedObjCount().orElse(MaintenanceConfig.DEFAULT_EXPECTED_OBJ_COUNT); + + for (var lastRunIter = + Streams.stream( + systemPersistence + .commits() + .commitLog( + MAINTENANCE_RUNS_REF_NAME, + OptionalLong.empty(), + MaintenanceRunsObj.class)) + .filter(Objects::nonNull) + .map(r -> systemPersistence.fetch(r.maintenanceRunId(), MaintenanceRunObj.class)) + .filter(Objects::nonNull) + .map(MaintenanceRunObj::runInformation) + .iterator(); + lastRunIter.hasNext(); ) { + var ri = lastRunIter.next(); + var refs = + Math.max( + ri.referenceStats().map(st -> st.scanned().orElse(0L)).orElse(0L), + ri.identifiedReferences().orElse(0L)); + var objs = + Math.max( + ri.objStats().map(st -> st.scanned().orElse(0L)).orElse(0L), + ri.identifiedObjs().orElse(0L)); + if (refs == 0L || objs == 0L) { + continue; + } + if (refs > expectedReferenceCount) { + // Add 10% to account for newly created references + expectedReferenceCount = + (long) + (refs + * effectiveConfig + .countFromLastRunMultiplier() + .orElse(DEFAULT_COUNT_FROM_LAST_RUN_MULTIPLIER)); + } + if (objs > expectedObjCount) { + // Add 10% to account for newly created objects + expectedObjCount = + (long) + (objs + * effectiveConfig + .countFromLastRunMultiplier() + .orElse(DEFAULT_COUNT_FROM_LAST_RUN_MULTIPLIER)); + } + } + + expectedReferenceCount = Math.max(expectedReferenceCount, 1_000); + expectedObjCount = Math.max(expectedObjCount, 100_000); + var configFpp = effectiveConfig.filterInitializedFpp().orElse(DEFAULT_INITIALIZED_FPP); + LOGGER.info( + "Sized retained collector for {} references and {} objects with an fpp of {}, approximate bloom filter heap sizes: {} and {} bytes", + expectedReferenceCount, + expectedObjCount, + configFpp, + bloomFilterBytes(expectedReferenceCount, configFpp), + bloomFilterBytes(expectedObjCount, configFpp)); + + return new AllRetained( + expectedReferenceCount, expectedObjCount, configFpp, ThreadLocalRandom.current().nextInt()); + } + + private static final double LOG2_SQUARED = Math.log(2) * Math.log(2); + + static long bloomFilterBytes(long elements, double fpp) { + var bits = (long) (-elements * Math.log(fpp) / LOG2_SQUARED); + return LongMath.divide(bits, 64, RoundingMode.CEILING); + } + + private boolean identifyAgainstRealm(String realmId, AllRetained allRetained) { + LOGGER.info("Identifying referenced data in realm '{}'", realmId); + + var pers = realmPersistenceFactory.newBuilder().realmId(realmId).build(); + var collector = new RetainedCollectorImpl(pers, allRetained, objTypeRetainedIdentifiers); + + boolean any = false; + for (var realmRetainedIdentifier : perRealmRetainedIdentifiers) { + LOGGER.info( + "Running maintenance for realm '{}' via '{}'", realmId, realmRetainedIdentifier.name()); + var handled = realmRetainedIdentifier.identifyRetained(collector); + LOGGER.info( + "Realm identifier '{}' {} {}", + realmRetainedIdentifier.name(), + handled ? "handled" : "did not handle", + realmId); + any |= handled; + } + return any; + } + + private MaintenanceRunObj initMaintenanceRunObj() { + return committer + .commitRuntimeException( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + var res = MaintenanceRunsObj.builder(); + + var ro = + MaintenanceRunObj.builder() + .id(systemPersistence.generateId()) + .runInformation( + MaintenanceRunInformation.builder() + .started(monotonicClock.currentInstant()) + .build()) + .build(); + res.maintenanceRunId(objRef(ro)); + + state.writeIfNew("ro", ro); + + return state.commitResult(ro, res, refObj); + }) + .orElseThrow(); + } + + private static void checkConfig(MaintenanceConfig config) { + config + .retainedRuns() + .ifPresent(v -> checkArgument(v > 1, "Number of maintenance runs must be at least 2")); + config + .expectedReferenceCount() + .ifPresent( + v -> + checkArgument( + v >= MaintenanceConfig.DEFAULT_EXPECTED_REFERENCE_COUNT, + "Expected reference count runs must be greater than or equal to %s", + MaintenanceConfig.DEFAULT_EXPECTED_REFERENCE_COUNT)); + config + .expectedObjCount() + .ifPresent( + v -> + checkArgument( + v >= MaintenanceConfig.DEFAULT_EXPECTED_OBJ_COUNT, + "Expected object count runs must be greater than or equal to %s", + MaintenanceConfig.DEFAULT_EXPECTED_OBJ_COUNT)); + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceServiceRealmRetainedIdentifier.java b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceServiceRealmRetainedIdentifier.java new file mode 100644 index 0000000000..568e0ed86a --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceServiceRealmRetainedIdentifier.java @@ -0,0 +1,57 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import static org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceConfig.DEFAULT_RETAINED_RUNS; +import static org.apache.polaris.persistence.nosql.maintenance.impl.MaintenanceRunsObj.MAINTENANCE_RUNS_REF_NAME; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceConfig; +import org.apache.polaris.persistence.nosql.maintenance.spi.CountDownPredicate; +import org.apache.polaris.persistence.nosql.maintenance.spi.PerRealmRetainedIdentifier; +import org.apache.polaris.persistence.nosql.maintenance.spi.RetainedCollector; + +/** Retained-identifier for the maintenance service's own reference and objects. */ +@SuppressWarnings("CdiInjectionPointsInspection") +@ApplicationScoped +class MaintenanceServiceRealmRetainedIdentifier implements PerRealmRetainedIdentifier { + @Inject MaintenanceConfig maintenanceConfig; + + @Override + public String name() { + return "Maintenance service"; + } + + @Override + public boolean identifyRetained(@Nonnull RetainedCollector collector) { + if (!collector.isSystemRealm()) { + return false; + } + + collector.refRetain( + MAINTENANCE_RUNS_REF_NAME, + MaintenanceRunsObj.class, + new CountDownPredicate<>(maintenanceConfig.retainedRuns().orElse(DEFAULT_RETAINED_RUNS)), + maintenance -> collector.retainObject(maintenance.maintenanceRunId())); + + return true; + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/RateLimit.java b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/RateLimit.java new file mode 100644 index 0000000000..bd525f6b73 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/RateLimit.java @@ -0,0 +1,53 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import com.google.common.util.concurrent.RateLimiter; + +interface RateLimit { + void acquire(); + + @SuppressWarnings("UnstableApiUsage") + static RateLimit create(int ratePerSecond) { + if (ratePerSecond <= 0 || ratePerSecond == Integer.MAX_VALUE) { + return new RateLimit() { + @Override + public void acquire() {} + + @Override + public String toString() { + return "unlimited"; + } + }; + } + return new RateLimit() { + final RateLimiter limiter = RateLimiter.create(ratePerSecond); + + @Override + public void acquire() { + limiter.acquire(); + } + + @Override + public String toString() { + return "up to " + ratePerSecond; + } + }; + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/RetainedCollectorImpl.java b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/RetainedCollectorImpl.java new file mode 100644 index 0000000000..6590de77fd --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/RetainedCollectorImpl.java @@ -0,0 +1,363 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Collections.emptyIterator; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; + +import com.google.common.collect.AbstractIterator; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.function.Supplier; +import org.apache.polaris.ids.api.IdGenerator; +import org.apache.polaris.ids.api.MonotonicClock; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.PersistenceParams; +import org.apache.polaris.persistence.nosql.api.commit.Commits; +import org.apache.polaris.persistence.nosql.api.commit.Committer; +import org.apache.polaris.persistence.nosql.api.exceptions.ReferenceAlreadyExistsException; +import org.apache.polaris.persistence.nosql.api.exceptions.ReferenceNotFoundException; +import org.apache.polaris.persistence.nosql.api.index.Index; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; +import org.apache.polaris.persistence.nosql.api.index.UpdatableIndex; +import org.apache.polaris.persistence.nosql.api.obj.BaseCommitObj; +import org.apache.polaris.persistence.nosql.api.obj.Obj; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.api.ref.Reference; +import org.apache.polaris.persistence.nosql.maintenance.spi.ObjTypeRetainedIdentifier; +import org.apache.polaris.persistence.nosql.maintenance.spi.RetainedCollector; + +/** {@link RetainedCollector} implementation, per realm. */ +final class RetainedCollectorImpl implements Persistence, RetainedCollector { + private final Persistence persistence; + private final AllRetained allRetained; + private final String realmId; + private final Map> objTypeRetainedIdentifiers; + + private final Set currentNesting = new HashSet<>(); + + RetainedCollectorImpl( + Persistence persistence, + AllRetained allRetained, + Map> objTypeRetainedIdentifiers) { + this.persistence = persistence; + this.allRetained = allRetained; + this.realmId = persistence.realmId(); + this.objTypeRetainedIdentifiers = objTypeRetainedIdentifiers; + } + + @Nonnull + @Override + public String realm() { + return realmId; + } + + @Nonnull + @Override + public Persistence realmPersistence() { + return this; + } + + @Override + public void retainObject(@Nonnull ObjRef objRef) { + if (!currentNesting.add(objRef.id())) { + return; + } + try { + allRetained.addRetainedObj(realmId, objRef.id()); + + var otIdents = objTypeRetainedIdentifiers.get(objRef.type()); + if (otIdents != null) { + for (var otIdent : otIdents) { + otIdent.identifyRelatedObj(this, objRef); + } + } + } finally { + currentNesting.remove(objRef.id()); + } + } + + @Override + public void retainReference(@Nonnull String name) { + allRetained.addRetainedRef(realmId, name); + } + + // Persistence delegate + + @Nonnull + @Override + public Reference createReference(@Nonnull String name, @Nonnull Optional pointer) + throws ReferenceAlreadyExistsException { + retainReference(name); + pointer.ifPresent(this::retainObject); + return persistence.createReference(name, pointer); + } + + @Override + public void createReferenceSilent(@Nonnull String name) { + retainReference(name); + persistence.createReferenceSilent(name); + } + + @Override + public void createReferencesSilent(Set referenceNames) { + referenceNames.forEach(this::retainReference); + persistence.createReferencesSilent(referenceNames); + } + + @Nonnull + @Override + public Reference fetchOrCreateReference( + @Nonnull String name, @Nonnull Supplier> pointerForCreate) { + try { + return fetchReference(name); + } catch (ReferenceNotFoundException e) { + try { + var objRef = pointerForCreate.get(); + objRef.ifPresent(this::retainObject); + return createReference(name, objRef); + } catch (ReferenceAlreadyExistsException x) { + // Unlikely that we ever get here (ref does not exist (but then concurrently created) + return fetchReference(name); + } + } + } + + @Nonnull + @Override + public Optional updateReferencePointer( + @Nonnull Reference reference, @Nonnull ObjRef newPointer) throws ReferenceNotFoundException { + retainReference(reference.name()); + retainObject(newPointer); + return persistence.updateReferencePointer(reference, newPointer); + } + + @Nonnull + @Override + public Reference fetchReference(@Nonnull String name) throws ReferenceNotFoundException { + retainReference(name); + var ref = persistence.fetchReference(name); + ref.pointer().ifPresent(this::retainObject); + return ref; + } + + @Nonnull + @Override + public Reference fetchReferenceForUpdate(@Nonnull String name) throws ReferenceNotFoundException { + retainReference(name); + var ref = persistence.fetchReferenceForUpdate(name); + ref.pointer().ifPresent(this::retainObject); + return ref; + } + + @Override + public Optional fetchReferenceHead( + @Nonnull String name, @Nonnull Class clazz) throws ReferenceNotFoundException { + retainReference(name); + var ref = persistence.fetchReferenceHead(name, clazz); + ref.ifPresent(head -> retainObject(objRef(head))); + return ref; + } + + @Nullable + @Override + public T fetch(@Nonnull ObjRef id, @Nonnull Class clazz) { + retainObject(id); + return persistence.fetch(id, clazz); + } + + @Nonnull + @Override + public T[] fetchMany(@Nonnull Class clazz, @Nonnull ObjRef... ids) { + for (var id : ids) { + if (id != null) { + retainObject(id); + } + } + return persistence.fetchMany(clazz, ids); + } + + @Nonnull + @Override + public T write(@Nonnull T obj, @Nonnull Class clazz) { + retainObject(objRef(obj)); + return persistence.write(obj, clazz); + } + + @SafeVarargs + @Nonnull + @Override + public final T[] writeMany(@Nonnull Class clazz, @Nonnull T... objs) { + for (var obj : objs) { + if (obj != null) { + retainObject(objRef(obj)); + } + } + return persistence.writeMany(clazz, objs); + } + + @Override + public void delete(@Nonnull ObjRef id) { + persistence.delete(id); + } + + @Override + public void deleteMany(@Nonnull ObjRef... ids) { + persistence.deleteMany(ids); + } + + @Nullable + @Override + public T conditionalInsert(@Nonnull T obj, @Nonnull Class clazz) { + retainObject(objRef(obj)); + return persistence.conditionalInsert(obj, clazz); + } + + @Nullable + @Override + public T conditionalUpdate( + @Nonnull T expected, @Nonnull T update, @Nonnull Class clazz) { + retainObject(objRef(update)); + return persistence.conditionalUpdate(expected, update, clazz); + } + + @Override + public boolean conditionalDelete(@Nonnull T expected, Class clazz) { + retainObject(objRef(expected)); + return persistence.conditionalDelete(expected, clazz); + } + + @Override + public PersistenceParams params() { + return persistence.params(); + } + + @Override + public int maxSerializedValueSize() { + return persistence.maxSerializedValueSize(); + } + + @Override + public long generateId() { + return persistence.generateId(); + } + + @Override + public ObjRef generateObjId(ObjType type) { + return persistence.generateObjId(type); + } + + @Nullable + @Override + public T getImmediate(@Nonnull ObjRef id, @Nonnull Class clazz) { + retainObject(id); + return persistence.getImmediate(id, clazz); + } + + @Override + public String realmId() { + return persistence.realmId(); + } + + @Override + public MonotonicClock monotonicClock() { + return persistence.monotonicClock(); + } + + @Override + public IdGenerator idGenerator() { + return persistence.idGenerator(); + } + + @Override + public UpdatableIndex buildWriteIndex( + @Nullable IndexContainer indexContainer, + @Nonnull IndexValueSerializer indexValueSerializer) { + return persistence.buildWriteIndex(indexContainer, indexValueSerializer); + } + + @Override + public Index buildReadIndex( + @Nullable IndexContainer indexContainer, + @Nonnull IndexValueSerializer indexValueSerializer) { + return persistence.buildReadIndex(indexContainer, indexValueSerializer); + } + + @Override + public Committer createCommitter( + @Nonnull String refName, + @Nonnull Class referencedObjType, + @Nonnull Class resultType) { + throw new UnsupportedOperationException( + "Committing operations not supported during retained-objects identification"); + } + + @Override + public Commits commits() { + return new Commits() { + @Override + public Iterator commitLog( + String refName, OptionalLong offset, Class clazz) { + checkArgument( + offset.isEmpty(), "Commit offset must be empty during retained-objects identification"); + + var ref = fetchReference(refName); + + return ref.pointer() + .map( + head -> + (Iterator) + new AbstractIterator() { + private ObjRef next = head; + + @Override + protected C computeNext() { + if (next == null) { + return endOfData(); + } + var r = fetch(next, clazz); + if (r == null) { + return endOfData(); + } + next = r.directParent().orElse(null); + return r; + } + }) + .orElse(emptyIterator()); + } + + @Override + public Iterator commitLogReversed( + String refName, long offset, Class clazz) { + throw new UnsupportedOperationException( + "Reversed commit scanning not supported during retained-objects identification"); + } + }; + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/ScanHandler.java b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/ScanHandler.java new file mode 100644 index 0000000000..502ee34924 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/ScanHandler.java @@ -0,0 +1,201 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import static org.apache.polaris.persistence.nosql.api.Realms.SYSTEM_REALM_PREFIX; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; + +import jakarta.annotation.Nonnull; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.OptionalInt; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.LongSupplier; +import org.apache.polaris.persistence.nosql.api.backend.Backend; +import org.apache.polaris.persistence.nosql.api.backend.PersistId; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +final class ScanHandler implements AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(ScanHandler.class); + + final String name; + final RateLimit rateLimit; + final long maxCreatedAtMicros; + final Set realmsToRetain; + final Set realmsToPurge; + final Consumer seenRealmsToPurge; + final RetainCheck retainCheck; + final int deleteBatchSize; + final Consumer>> batchDelete; + final ScanItemCallback itemCallback; + + final Map> deletions = new HashMap<>(); + int numDeletes; + + ScanHandler( + String name, + OptionalInt rateLimit, + long maxCreatedAtMicros, + Set realmsToRetain, + Set realmsToPurge, + Consumer seenRealmsToPurge, + RetainCheck retainCheck, + int deleteBatchSize, + Consumer>> batchDelete, + ScanItemCallback itemCallback) { + this.name = name; + this.rateLimit = RateLimit.create(rateLimit.orElse(-1)); + this.maxCreatedAtMicros = maxCreatedAtMicros; + this.realmsToRetain = realmsToRetain; + this.realmsToPurge = realmsToPurge; + this.seenRealmsToPurge = seenRealmsToPurge; + this.retainCheck = retainCheck; + this.deleteBatchSize = deleteBatchSize; + this.batchDelete = batchDelete; + this.itemCallback = itemCallback; + } + + void scanned(String realmId, I id, long createdAtMicros) { + if (realmId.startsWith(SYSTEM_REALM_PREFIX) && !realmsToRetain.contains(realmId)) { + // some system realm, ignore + return; + } + + rateLimit.acquire(); + ScanItemOutcome outcome; + if (realmsToPurge.contains(realmId)) { + outcome = ScanItemOutcome.REALM_PURGE; + purge(realmId, id); + seenRealmsToPurge.accept(realmId); + } else if (createdAtMicros > maxCreatedAtMicros) { + outcome = ScanItemOutcome.TOO_NEW_RETAINED; + } else if (realmsToRetain.contains(realmId)) { + if (retainCheck.check(realmId, id)) { + outcome = ScanItemOutcome.RETAINED; + } else { + outcome = ScanItemOutcome.PURGED; + purge(realmId, id); + } + } else { + outcome = ScanItemOutcome.UNHANDLED_RETAINED; + } + itemCallback.itemOutcome(realmId, id, outcome); + LOGGER.debug( + "Got '{}' {} {} -> {}, createdAtMicros = {}", + realmId, + name, + id, + outcome.message, + createdAtMicros); + } + + private void purge(String realmId, I id) { + LOGGER.debug("Enqueuing delete for '{}' {}", realmId, id); + deletions.computeIfAbsent(realmId, k -> new HashSet<>()).add(id); + numDeletes++; + if (numDeletes == deleteBatchSize) { + flushDeletes(); + } + } + + private void flushDeletes() { + LOGGER.debug("Flushing {} {} deletions", numDeletes, name); + batchDelete.accept(deletions); + deletions.clear(); + numDeletes = 0; + } + + @Override + public void close() { + if (numDeletes > 0) { + flushDeletes(); + } + } + + public Backend.ObjScanCallback asObjScanCallback(LongSupplier clock) { + return new ProgressObjScanCallback(clock); + } + + public Backend.ReferenceScanCallback asReferenceScanCallback(LongSupplier clock) { + return new ProgressReferenceScanCallback(clock); + } + + @FunctionalInterface + interface RetainCheck { + boolean check(String realm, I id); + } + + private abstract static class ProgressCallback { + private final LongSupplier clock; + private long nextLog; + private long scanned; + + ProgressCallback(LongSupplier clock) { + this.clock = clock; + nextLog = clock.getAsLong() + 2_000L; + } + + protected void called(String what) { + var s = scanned++; + var now = clock.getAsLong(); + if (now >= nextLog) { + LOGGER.info("... scanned {} {} so far", s, what); + nextLog = now + 2_000L; + } + } + } + + private class ProgressReferenceScanCallback extends ProgressCallback + implements Backend.ReferenceScanCallback { + + ProgressReferenceScanCallback(LongSupplier clock) { + super(clock); + } + + @SuppressWarnings("unchecked") + @Override + public void call(@Nonnull String realmId, @Nonnull String refName, long createdAtMicros) { + called("references"); + ((ScanHandler) ScanHandler.this).scanned(realmId, refName, createdAtMicros); + } + } + + private class ProgressObjScanCallback extends ProgressCallback + implements Backend.ObjScanCallback { + ProgressObjScanCallback(LongSupplier clock) { + super(clock); + } + + @SuppressWarnings("unchecked") + @Override + public void call( + @Nonnull String realmId, + @Nonnull String type, + @Nonnull PersistId id, + long createdAtMicros) { + called("objects"); + ((ScanHandler) ScanHandler.this) + .scanned(realmId, objRef(type, id.id(), id.part()), createdAtMicros); + } + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/ScanItemCallback.java b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/ScanItemCallback.java new file mode 100644 index 0000000000..8d197cbb55 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/ScanItemCallback.java @@ -0,0 +1,26 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import jakarta.annotation.Nonnull; + +@FunctionalInterface +interface ScanItemCallback { + void itemOutcome(@Nonnull String realm, @Nonnull I id, @Nonnull ScanItemOutcome outcome); +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/ScanItemOutcome.java b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/ScanItemOutcome.java new file mode 100644 index 0000000000..f1a1565a25 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/ScanItemOutcome.java @@ -0,0 +1,34 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +enum ScanItemOutcome { + REALM_PURGE("realm purge"), + TOO_NEW_RETAINED("too new"), + RETAINED("retained"), + PURGED("purged"), + UNHANDLED_RETAINED("unhandled/retained"), + ; + + final String message; + + ScanItemOutcome(String message) { + this.message = message; + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/package-info.java b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/package-info.java new file mode 100644 index 0000000000..9247f45baa --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/java/org/apache/polaris/persistence/nosql/maintenance/impl/package-info.java @@ -0,0 +1,28 @@ +/* + * 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. + */ + +/** + * Maintenance service implementation: do not directly use the types in this package. + * + *

Uses bloom filters to "collect" the references and objects to retain. The sizing of both + * filters uses the values of scanned references/objects of the last successful maintenance + * run, plus 10%. If no successful maintenance service run is present, the values of the maintenance + * configuration will be used. + */ +package org.apache.polaris.persistence.nosql.maintenance.impl; diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/resources/META-INF/beans.xml b/persistence/nosql/persistence/maintenance/impl/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/persistence/maintenance/impl/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType b/persistence/nosql/persistence/maintenance/impl/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType new file mode 100644 index 0000000000..e1050243b8 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType @@ -0,0 +1,21 @@ +# +# 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. +# + +org.apache.polaris.persistence.nosql.maintenance.impl.MaintenanceRunsObj$MaintenanceRunsObjType +org.apache.polaris.persistence.nosql.maintenance.impl.MaintenanceRunObj$MaintenanceRunObjType diff --git a/persistence/nosql/persistence/maintenance/impl/src/test/java/org/apache/polaris/persistence/nosql/maintenance/impl/TestMaintenance.java b/persistence/nosql/persistence/maintenance/impl/src/test/java/org/apache/polaris/persistence/nosql/maintenance/impl/TestMaintenance.java new file mode 100644 index 0000000000..131065817f --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/test/java/org/apache/polaris/persistence/nosql/maintenance/impl/TestMaintenance.java @@ -0,0 +1,393 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.apache.polaris.persistence.nosql.maintenance.impl.MutableMaintenanceConfig.GRACE_TIME; + +import jakarta.inject.Inject; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.polaris.ids.api.SnowflakeIdGenerator; +import org.apache.polaris.ids.mocks.MutableMonotonicClock; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.RealmPersistenceFactory; +import org.apache.polaris.persistence.nosql.api.exceptions.ReferenceNotFoundException; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceConfig; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceRunInformation.MaintenanceStats; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceRunSpec; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceService; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.jboss.weld.junit5.EnableWeld; +import org.jboss.weld.junit5.WeldInitiator; +import org.jboss.weld.junit5.WeldSetup; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@SuppressWarnings("CdiInjectionPointsInspection") +@ExtendWith(SoftAssertionsExtension.class) +@EnableWeld +public class TestMaintenance { + @InjectSoftAssertions protected SoftAssertions soft; + + @WeldSetup WeldInitiator weld = WeldInitiator.performDefaultDiscovery(); + + String realmOne; + String realmTwo; + Persistence persOne; + Persistence persTwo; + + @Inject MaintenanceService maintenance; + @Inject RealmPersistenceFactory realmPersistenceFactory; + @Inject MutableMonotonicClock mutableMonotonicClock; + + @BeforeEach + protected void setup() { + RealmIdentOne.testCallback = c -> true; + RealmIdentTwo.testCallback = c -> true; + ObjTypeIdentOne.testCallback = (c, id) -> {}; + ObjTypeIdentTwo.testCallback = (c, id) -> {}; + + // Set the "grace time" to 0 so tests can write refs+objs and get those purged + MutableMaintenanceConfig.setCurrent( + MaintenanceConfig.builder().createdAtGraceTime(GRACE_TIME).build()); + + realmOne = UUID.randomUUID().toString(); + realmTwo = UUID.randomUUID().toString(); + + // 'skipDecorators' is used to bypass the cache, which cannot be consistent after maintenance + // purged some references/objects + persOne = realmPersistenceFactory.newBuilder().realmId(realmOne).skipDecorators().build(); + persTwo = realmPersistenceFactory.newBuilder().realmId(realmTwo).skipDecorators().build(); + } + + @AfterEach + protected void cleanup() { + mutableMonotonicClock.advanceBoth(GRACE_TIME); + + // Use maintenance to clean the backend for the next test + maintenance.performMaintenance( + MaintenanceRunSpec.builder() + .includeSystemRealm(false) + .realmsToPurge(Set.of(realmOne, realmTwo)) + .build()); + } + + @Test + public void noRealmsSpecified() { + persOne.write(ObjOne.builder().text("foo").id(persOne.generateId()).build(), ObjOne.class); + persTwo.write(ObjTwo.builder().text("bar").id(persTwo.generateId()).build(), ObjTwo.class); + + persOne.createReference("ref1", Optional.empty()); + persTwo.createReference("ref1", Optional.empty()); + + mutableMonotonicClock.advanceBoth(GRACE_TIME); + + // Run maintenance, no realm given to retain or purge, must not purge anything + var runInfo = + maintenance.performMaintenance( + MaintenanceRunSpec.builder().includeSystemRealm(false).build()); + + soft.assertThat(runInfo.referenceStats()) + .contains(MaintenanceStats.builder().scanned(2L).purged(0L).retained(2L).newer(0L).build()); + soft.assertThat(runInfo.objStats()) + .contains(MaintenanceStats.builder().scanned(2L).purged(0L).retained(2L).newer(0L).build()); + soft.assertThat(runInfo.identifiedReferences().orElse(-1)).isEqualTo(0L); + soft.assertThat(runInfo.identifiedObjs().orElse(-1)).isEqualTo(0L); + } + + @Test + public void simple() { + var rOneObj1 = + persOne.write(ObjOne.builder().text("foo").id(persOne.generateId()).build(), ObjOne.class); + var rTwoObj2 = + persTwo.write(ObjTwo.builder().text("bar").id(persTwo.generateId()).build(), ObjTwo.class); + + var systemRealmCalled = new AtomicInteger(); + RealmIdentOne.testCallback = + collector -> { + if (collector.isSystemRealm()) { + systemRealmCalled.incrementAndGet(); + } + return true; + }; + + persOne.createReference("ref1", Optional.empty()); + persOne.createReference("ref2", Optional.empty()); + persTwo.createReference("ref1", Optional.empty()); + persTwo.createReference("ref2", Optional.empty()); + + mutableMonotonicClock.advanceBoth(GRACE_TIME); + + // Run maintenance, provide realms, must purge unidentified + var runInfo = + maintenance.performMaintenance( + MaintenanceRunSpec.builder() + .includeSystemRealm(false) + .realmsToProcess(Set.of(realmOne, realmTwo)) + .build()); + + soft.assertThat(runInfo.referenceStats()) + .contains(MaintenanceStats.builder().scanned(4L).purged(4L).retained(0L).newer(0L).build()); + soft.assertThat(runInfo.objStats()) + .contains(MaintenanceStats.builder().scanned(2L).purged(2L).retained(0L).newer(0L).build()); + soft.assertThat(runInfo.identifiedReferences().orElse(-1)).isEqualTo(0L); + soft.assertThat(runInfo.identifiedObjs().orElse(-1)).isEqualTo(0L); + + soft.assertThatExceptionOfType(ReferenceNotFoundException.class) + .isThrownBy(() -> persOne.fetchReference("ref1")); + soft.assertThatExceptionOfType(ReferenceNotFoundException.class) + .isThrownBy(() -> persOne.fetchReference("ref2")); + soft.assertThatExceptionOfType(ReferenceNotFoundException.class) + .isThrownBy(() -> persTwo.fetchReference("ref1")); + soft.assertThatExceptionOfType(ReferenceNotFoundException.class) + .isThrownBy(() -> persTwo.fetchReference("ref2")); + + soft.assertThat(persOne.fetch(objRef(rOneObj1), ObjOne.class)).isNull(); + soft.assertThat(persTwo.fetch(objRef(rTwoObj2), ObjTwo.class)).isNull(); + } + + @Test + public void systemRealm() { + var systemRealmCalled = new AtomicInteger(); + RealmIdentOne.testCallback = + collector -> { + if (collector.isSystemRealm()) { + systemRealmCalled.incrementAndGet(); + } + return true; + }; + + mutableMonotonicClock.advanceBoth(GRACE_TIME); + + // Run maintenance, provide realms, must purge unidentified + var runInfo = + maintenance.performMaintenance( + MaintenanceRunSpec.builder() + .includeSystemRealm(true) // default + .build()); + + soft.assertThat(systemRealmCalled).hasValue(1); + + soft.assertThat(runInfo.referenceStats()) + .contains(MaintenanceStats.builder().scanned(1L).purged(0L).retained(0L).newer(1L).build()); + soft.assertThat(runInfo.objStats()) + .contains(MaintenanceStats.builder().scanned(4L).purged(0L).retained(0L).newer(4L).build()); + soft.assertThat(runInfo.identifiedReferences().orElse(-1)).isEqualTo(2L); + soft.assertThat(runInfo.identifiedObjs().orElse(-1)) + .isEqualTo((1 << SnowflakeIdGenerator.DEFAULT_NODE_ID_BITS) + 4L); + } + + @Test + public void simpleRetainViaRealmIdentifier() { + var rOneObj1 = + persOne.write(ObjOne.builder().text("foo").id(persOne.generateId()).build(), ObjOne.class); + var rTwoObj2 = + persTwo.write(ObjTwo.builder().text("bar").id(persTwo.generateId()).build(), ObjTwo.class); + + persOne.createReference("ref1", Optional.empty()); + persOne.createReference("ref2", Optional.empty()); + persTwo.createReference("ref1", Optional.empty()); + persTwo.createReference("ref2", Optional.empty()); + + // identify rOneObj1 as "live" + RealmIdentOne.testCallback = + c -> { + if (c.realm().equals(realmOne)) { + c.retainObject(objRef(rOneObj1)); + c.retainReference("ref1"); + } + return true; + }; + + mutableMonotonicClock.advanceBoth(GRACE_TIME); + + // Run maintenance, provide realms, must purge unidentified + var runInfo = + maintenance.performMaintenance( + MaintenanceRunSpec.builder() + .includeSystemRealm(false) + .realmsToProcess(Set.of(realmOne, realmTwo)) + .build()); + + soft.assertThat(runInfo.referenceStats()) + .contains(MaintenanceStats.builder().scanned(4L).purged(3L).retained(1L).newer(0L).build()); + soft.assertThat(runInfo.objStats()) + .contains(MaintenanceStats.builder().scanned(2L).purged(1L).retained(1L).newer(0L).build()); + soft.assertThat(runInfo.identifiedReferences().orElse(-1)).isEqualTo(1L); + soft.assertThat(runInfo.identifiedObjs().orElse(-1)).isEqualTo(1L); + + soft.assertThatCode(() -> persOne.fetchReference("ref1")).doesNotThrowAnyException(); + soft.assertThatExceptionOfType(ReferenceNotFoundException.class) + .isThrownBy(() -> persOne.fetchReference("ref2")); + soft.assertThatExceptionOfType(ReferenceNotFoundException.class) + .isThrownBy(() -> persTwo.fetchReference("ref1")); + soft.assertThatExceptionOfType(ReferenceNotFoundException.class) + .isThrownBy(() -> persTwo.fetchReference("ref2")); + + soft.assertThat(persOne.fetch(objRef(rOneObj1), ObjOne.class)).isEqualTo(rOneObj1); + soft.assertThat(persTwo.fetch(objRef(rTwoObj2), ObjTwo.class)).isNull(); + } + + @Test + public void simpleRetainViaRealmIdentifierPersistence() { + var rOneObj1 = + persOne.write(ObjOne.builder().text("foo").id(persOne.generateId()).build(), ObjOne.class); + var rTwoObj2 = + persTwo.write(ObjTwo.builder().text("bar").id(persTwo.generateId()).build(), ObjTwo.class); + + persOne.createReference("ref1", Optional.empty()); + persOne.createReference("ref2", Optional.empty()); + persTwo.createReference("ref1", Optional.empty()); + persTwo.createReference("ref2", Optional.empty()); + + // identify rOneObj1 as "live" + RealmIdentOne.testCallback = + c -> { + if (c.realm().equals(realmOne)) { + c.realmPersistence().fetch(objRef(rOneObj1), ObjOne.class); + c.realmPersistence().fetchReference("ref1"); + } + return true; + }; + + mutableMonotonicClock.advanceBoth(GRACE_TIME); + + // Run maintenance, provide realms, must purge unidentified + var runInfo = + maintenance.performMaintenance( + MaintenanceRunSpec.builder() + .includeSystemRealm(false) + .realmsToProcess(Set.of(realmOne, realmTwo)) + .build()); + + soft.assertThat(runInfo.referenceStats()) + .contains(MaintenanceStats.builder().scanned(4L).purged(3L).retained(1L).newer(0L).build()); + soft.assertThat(runInfo.objStats()) + .contains(MaintenanceStats.builder().scanned(2L).purged(1L).retained(1L).newer(0L).build()); + soft.assertThat(runInfo.identifiedReferences().orElse(-1)).isEqualTo(1L); + soft.assertThat(runInfo.identifiedObjs().orElse(-1)).isEqualTo(1L); + + soft.assertThatCode(() -> persOne.fetchReference("ref1")).doesNotThrowAnyException(); + soft.assertThatExceptionOfType(ReferenceNotFoundException.class) + .isThrownBy(() -> persOne.fetchReference("ref2")); + soft.assertThatExceptionOfType(ReferenceNotFoundException.class) + .isThrownBy(() -> persTwo.fetchReference("ref1")); + soft.assertThatExceptionOfType(ReferenceNotFoundException.class) + .isThrownBy(() -> persTwo.fetchReference("ref2")); + + soft.assertThat(persOne.fetch(objRef(rOneObj1), ObjOne.class)).isEqualTo(rOneObj1); + soft.assertThat(persTwo.fetch(objRef(rTwoObj2), ObjTwo.class)).isNull(); + } + + @Test + public void simpleRetainViaObjTypeIdentifier() { + var rOneObj1 = + persOne.write(ObjOne.builder().text("foo1").id(persOne.generateId()).build(), ObjOne.class); + var rOneObj2 = + persOne.write(ObjTwo.builder().text("foo2").id(persOne.generateId()).build(), ObjTwo.class); + var rTwoObj1 = + persTwo.write(ObjOne.builder().text("bar2").id(persTwo.generateId()).build(), ObjOne.class); + var rTwoObj2 = + persTwo.write(ObjTwo.builder().text("bar2").id(persTwo.generateId()).build(), ObjTwo.class); + + // identify rOneObj1 as "live" + RealmIdentOne.testCallback = + c -> { + c.retainObject(objRef(rOneObj1)); + return true; + }; + // identify rObjObj2 via obj-type callback + ObjTypeIdentOne.testCallback = + (c, id) -> { + if (id.equals(objRef(rOneObj1))) { + c.retainObject(objRef(rOneObj2)); + } + }; + // identify rTwoObj1 + RealmIdentTwo.testCallback = + c -> { + c.retainObject(objRef(rTwoObj1)); + return true; + }; + + mutableMonotonicClock.advanceBoth(GRACE_TIME); + + // Run maintenance, provide realms, must purge unidentified + var runInfo = + maintenance.performMaintenance( + MaintenanceRunSpec.builder() + .includeSystemRealm(false) + .realmsToProcess(Set.of(realmOne, realmTwo)) + .build()); + + soft.assertThat(runInfo.referenceStats()) + .contains(MaintenanceStats.builder().scanned(0L).purged(0L).retained(0L).newer(0L).build()); + soft.assertThat(runInfo.objStats()) + .contains(MaintenanceStats.builder().scanned(4L).purged(1L).retained(3L).newer(0L).build()); + soft.assertThat(runInfo.identifiedReferences().orElse(-1)).isEqualTo(0L); + soft.assertThat(runInfo.identifiedObjs().orElse(-1)).isEqualTo(6L); + + soft.assertThat(persOne.fetch(objRef(rOneObj1), ObjOne.class)).isEqualTo(rOneObj1); + soft.assertThat(persOne.fetch(objRef(rOneObj2), ObjTwo.class)).isEqualTo(rOneObj2); + soft.assertThat(persTwo.fetch(objRef(rTwoObj1), ObjOne.class)).isEqualTo(rTwoObj1); + soft.assertThat(persTwo.fetch(objRef(rTwoObj2), ObjTwo.class)).isNull(); + } + + @Test + public void noRealmIdentifierHandlesRealms() { + var rOneObj1 = + persOne.write(ObjOne.builder().text("foo").id(persOne.generateId()).build(), ObjOne.class); + var rTwoObj2 = + persTwo.write(ObjTwo.builder().text("bar").id(persTwo.generateId()).build(), ObjTwo.class); + persOne.createReference("ref1", Optional.empty()); + persOne.createReference("ref2", Optional.empty()); + persTwo.createReference("ref1", Optional.empty()); + persTwo.createReference("ref2", Optional.empty()); + + RealmIdentOne.testCallback = c -> false; + RealmIdentTwo.testCallback = c -> false; + + mutableMonotonicClock.advanceBoth(GRACE_TIME); + + // Run maintenance, provide realms, no realm-identifier handles realm, must NOT purge + var runInfo = + maintenance.performMaintenance( + MaintenanceRunSpec.builder() + .includeSystemRealm(false) + .realmsToProcess(Set.of(realmOne, realmTwo)) + .build()); + + soft.assertThat(runInfo.referenceStats()) + .contains(MaintenanceStats.builder().scanned(4L).purged(0L).retained(4L).newer(0L).build()); + soft.assertThat(runInfo.objStats()) + .contains(MaintenanceStats.builder().scanned(2L).purged(0L).retained(2L).newer(0L).build()); + soft.assertThat(runInfo.identifiedReferences().orElse(-1)).isEqualTo(0L); + soft.assertThat(runInfo.identifiedObjs().orElse(-1)).isEqualTo(0L); + + soft.assertThat(persOne.fetch(objRef(rOneObj1), ObjOne.class)).isEqualTo(rOneObj1); + soft.assertThat(persTwo.fetch(objRef(rTwoObj2), ObjTwo.class)).isEqualTo(rTwoObj2); + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/test/resources/weld.properties b/persistence/nosql/persistence/maintenance/impl/src/test/resources/weld.properties new file mode 100644 index 0000000000..c26169e0e1 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/test/resources/weld.properties @@ -0,0 +1,21 @@ +# +# 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. +# + +# See https://bugs.openjdk.org/browse/JDK-8349545 +org.jboss.weld.bootstrap.concurrentDeployment=false diff --git a/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceConfigurationProducer.java b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceConfigurationProducer.java new file mode 100644 index 0000000000..3e283e9557 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/MaintenanceConfigurationProducer.java @@ -0,0 +1,33 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceConfig; + +@ApplicationScoped +public class MaintenanceConfigurationProducer { + public static MutableMaintenanceConfig config = new MutableMaintenanceConfig(); + + @Produces + MaintenanceConfig produceMaintenanceConfig() { + return config; + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/MutableMaintenanceConfig.java b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/MutableMaintenanceConfig.java new file mode 100644 index 0000000000..d2bbc31ad8 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/MutableMaintenanceConfig.java @@ -0,0 +1,102 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import static org.apache.polaris.persistence.nosql.maintenance.impl.MaintenanceServiceImpl.MIN_GRACE_TIME_MINUTES; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Duration; +import java.util.Optional; +import java.util.OptionalDouble; +import java.util.OptionalInt; +import java.util.OptionalLong; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceConfig; + +public class MutableMaintenanceConfig implements MaintenanceConfig { + /** Minimum allowed by MaintenanceServiceImpl. */ + public static final Duration GRACE_TIME = Duration.ofMinutes(MIN_GRACE_TIME_MINUTES); + + private static MaintenanceConfig current = MaintenanceConfig.builder().build(); + + public static void setCurrent(MaintenanceConfig config) { + current = config; + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @Override + public OptionalLong expectedReferenceCount() { + return current.expectedReferenceCount(); + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @Override + public OptionalLong expectedObjCount() { + return current.expectedObjCount(); + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @Override + public OptionalDouble countFromLastRunMultiplier() { + return current.countFromLastRunMultiplier(); + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @Override + public OptionalDouble filterInitializedFpp() { + return current.filterInitializedFpp(); + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @Override + public OptionalDouble maxAcceptableFilterFpp() { + return current.maxAcceptableFilterFpp(); + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @Override + public OptionalInt retainedRuns() { + return current.retainedRuns(); + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonFormat(shape = JsonFormat.Shape.STRING) + @Override + public Optional createdAtGraceTime() { + return current.createdAtGraceTime(); + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @Override + public OptionalInt objectScanRateLimitPerSecond() { + return current.objectScanRateLimitPerSecond(); + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @Override + public OptionalInt referenceScanRateLimitPerSecond() { + return current.referenceScanRateLimitPerSecond(); + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @Override + public OptionalInt deleteBatchSize() { + return current.deleteBatchSize(); + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/ObjOne.java b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/ObjOne.java new file mode 100644 index 0000000000..72afcc0767 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/ObjOne.java @@ -0,0 +1,53 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import jakarta.annotation.Nullable; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.Obj; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; + +@PolarisImmutable +@JsonSerialize(as = ImmutableObjOne.class) +@JsonDeserialize(as = ImmutableObjOne.class) +public interface ObjOne extends Obj { + + ObjType TYPE = new ObjOneType(); + + @Override + default ObjType type() { + return TYPE; + } + + @Nullable + String text(); + + static ImmutableObjOne.Builder builder() { + return ImmutableObjOne.builder(); + } + + final class ObjOneType extends AbstractObjType { + public ObjOneType() { + super("maint-test-one", "maint-one", ObjOne.class); + } + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/ObjTwo.java b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/ObjTwo.java new file mode 100644 index 0000000000..571e5d20ca --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/ObjTwo.java @@ -0,0 +1,53 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import jakarta.annotation.Nullable; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.Obj; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; + +@PolarisImmutable +@JsonSerialize(as = ImmutableObjTwo.class) +@JsonDeserialize(as = ImmutableObjTwo.class) +public interface ObjTwo extends Obj { + + ObjType TYPE = new ObjTwoType(); + + @Override + default ObjType type() { + return TYPE; + } + + @Nullable + String text(); + + static ImmutableObjTwo.Builder builder() { + return ImmutableObjTwo.builder(); + } + + final class ObjTwoType extends AbstractObjType { + public ObjTwoType() { + super("maint-test-two", "maint-two", ObjTwo.class); + } + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/ObjTypeIdentOne.java b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/ObjTypeIdentOne.java new file mode 100644 index 0000000000..be9ef1cd33 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/ObjTypeIdentOne.java @@ -0,0 +1,51 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.function.BiConsumer; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.maintenance.spi.ObjTypeRetainedIdentifier; +import org.apache.polaris.persistence.nosql.maintenance.spi.RetainedCollector; + +@ApplicationScoped +public class ObjTypeIdentOne implements ObjTypeRetainedIdentifier { + + static BiConsumer testCallback; + + @Override + public String name() { + return "TEST ObjTypeRetainedIdentifier ONE"; + } + + @Nonnull + @Override + public ObjType handledObjType() { + return ObjOne.TYPE; + } + + @Override + public void identifyRelatedObj(@Nonnull RetainedCollector collector, @Nonnull ObjRef objRef) { + if (testCallback != null) { + testCallback.accept(collector, objRef); + } + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/ObjTypeIdentTwo.java b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/ObjTypeIdentTwo.java new file mode 100644 index 0000000000..9052a38825 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/ObjTypeIdentTwo.java @@ -0,0 +1,50 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.function.BiConsumer; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.maintenance.spi.ObjTypeRetainedIdentifier; +import org.apache.polaris.persistence.nosql.maintenance.spi.RetainedCollector; + +@ApplicationScoped +public class ObjTypeIdentTwo implements ObjTypeRetainedIdentifier { + static BiConsumer testCallback; + + @Override + public String name() { + return "TEST ObjTypeRetainedIdentifier TWO"; + } + + @Nonnull + @Override + public ObjType handledObjType() { + return ObjTwo.TYPE; + } + + @Override + public void identifyRelatedObj(@Nonnull RetainedCollector collector, @Nonnull ObjRef objRef) { + if (testCallback != null) { + testCallback.accept(collector, objRef); + } + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/RealmIdentOne.java b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/RealmIdentOne.java new file mode 100644 index 0000000000..229aa2ac43 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/RealmIdentOne.java @@ -0,0 +1,40 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.function.Function; +import org.apache.polaris.persistence.nosql.maintenance.spi.PerRealmRetainedIdentifier; +import org.apache.polaris.persistence.nosql.maintenance.spi.RetainedCollector; + +@ApplicationScoped +public class RealmIdentOne implements PerRealmRetainedIdentifier { + static Function testCallback; + + @Override + public String name() { + return "TEST RealmRetainedIdentifier ONE"; + } + + @Override + public boolean identifyRetained(@Nonnull RetainedCollector collector) { + return testCallback != null ? testCallback.apply(collector) : false; + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/RealmIdentTwo.java b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/RealmIdentTwo.java new file mode 100644 index 0000000000..22f2a61db8 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/RealmIdentTwo.java @@ -0,0 +1,40 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.function.Function; +import org.apache.polaris.persistence.nosql.maintenance.spi.PerRealmRetainedIdentifier; +import org.apache.polaris.persistence.nosql.maintenance.spi.RetainedCollector; + +@ApplicationScoped +public class RealmIdentTwo implements PerRealmRetainedIdentifier { + static Function testCallback; + + @Override + public String name() { + return "TEST RealmRetainedIdentifier TWO"; + } + + @Override + public boolean identifyRetained(@Nonnull RetainedCollector collector) { + return testCallback != null ? testCallback.apply(collector) : false; + } +} diff --git a/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/package-info.java b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/package-info.java new file mode 100644 index 0000000000..b486d52fae --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/maintenance/impl/package-info.java @@ -0,0 +1,20 @@ +/* + * 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.polaris.persistence.nosql.maintenance.impl; diff --git a/persistence/nosql/persistence/maintenance/impl/src/testFixtures/resources/META-INF/beans.xml b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/persistence/maintenance/impl/src/testFixtures/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType new file mode 100644 index 0000000000..c2c5d5f950 --- /dev/null +++ b/persistence/nosql/persistence/maintenance/impl/src/testFixtures/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType @@ -0,0 +1,21 @@ +# +# 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. +# + +org.apache.polaris.persistence.nosql.maintenance.impl.ObjOne$ObjOneType +org.apache.polaris.persistence.nosql.maintenance.impl.ObjTwo$ObjTwoType diff --git a/persistence/nosql/persistence/metastore/build.gradle.kts b/persistence/nosql/persistence/metastore/build.gradle.kts new file mode 100644 index 0000000000..d01581ebb2 --- /dev/null +++ b/persistence/nosql/persistence/metastore/build.gradle.kts @@ -0,0 +1,71 @@ +/* + * 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris NoSQL persistence - bridge to meta-store" + +dependencies { + implementation(project(":polaris-core")) + + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-persistence-nosql-types")) + implementation(project(":polaris-persistence-nosql-authz-api")) + implementation(project(":polaris-persistence-nosql-authz-spi")) + implementation(project(":polaris-persistence-nosql-realms-api")) + implementation(project(":polaris-idgen-api")) + + implementation(platform(libs.iceberg.bom)) + implementation("org.apache.iceberg:iceberg-api") + implementation("org.apache.iceberg:iceberg-core") + + implementation(libs.guava) + implementation(libs.slf4j.api) + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-annotations") + implementation("com.fasterxml.jackson.core:jackson-core") + implementation("com.fasterxml.jackson.core:jackson-databind") + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + compileOnly(libs.smallrye.common.annotation) + + testImplementation(testFixtures(project(":polaris-core"))) + testRuntimeOnly(project(":polaris-persistence-nosql-authz-impl")) + testRuntimeOnly(project(":polaris-persistence-nosql-authz-store-nosql")) + testRuntimeOnly(testFixtures(project(":polaris-persistence-nosql-cdi-weld"))) + testImplementation(testFixtures(project(":polaris-persistence-nosql-types"))) + testImplementation(libs.weld.se.core) + testImplementation(libs.weld.junit5) + testRuntimeOnly(libs.smallrye.jandex) + + testCompileOnly(libs.jakarta.annotation.api) + testCompileOnly(libs.jakarta.validation.api) + testCompileOnly(libs.jakarta.enterprise.cdi.api) + testCompileOnly(libs.smallrye.common.annotation) +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/CatalogChangeCommitter.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/CatalogChangeCommitter.java new file mode 100644 index 0000000000..208ed7d43e --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/CatalogChangeCommitter.java @@ -0,0 +1,42 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import jakarta.annotation.Nonnull; +import org.apache.polaris.persistence.nosql.api.commit.CommitException; +import org.apache.polaris.persistence.nosql.api.commit.CommitterState; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.index.UpdatableIndex; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogStateObj; +import org.apache.polaris.persistence.nosql.coretypes.catalog.LongValues; +import org.apache.polaris.persistence.nosql.coretypes.changes.Change; + +@FunctionalInterface +interface CatalogChangeCommitter { + @Nonnull + ChangeResult change( + @Nonnull CommitterState state, + @Nonnull CatalogStateObj.Builder ref, + @Nonnull UpdatableIndex byName, + @Nonnull UpdatableIndex byId, + @Nonnull UpdatableIndex changes, + @Nonnull UpdatableIndex locations) + throws CommitException; +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/CatalogChangeCommitterWrapper.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/CatalogChangeCommitterWrapper.java new file mode 100644 index 0000000000..1e164875b7 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/CatalogChangeCommitterWrapper.java @@ -0,0 +1,88 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static org.apache.polaris.persistence.nosql.api.index.IndexContainer.newUpdatableIndex; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.INDEX_KEY_SERIALIZER; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.coretypes.catalog.LongValues.LONG_VALUES_SERIALIZER; +import static org.apache.polaris.persistence.nosql.coretypes.changes.Change.CHANGE_SERIALIZER; + +import jakarta.annotation.Nonnull; +import java.util.Optional; +import java.util.function.Supplier; +import org.apache.polaris.persistence.nosql.api.commit.CommitException; +import org.apache.polaris.persistence.nosql.api.commit.CommitRetryable; +import org.apache.polaris.persistence.nosql.api.commit.CommitterState; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogStateObj; + +/** + * Abstracts common {@link ContainerObj#stableIdToName()} and {@link ContainerObj#nameToObjRef()} + * handling for committing operations, for catalog related types. + * + * @param result of the commiting operation + */ +record CatalogChangeCommitterWrapper(CatalogChangeCommitter changeCommitter) + implements CommitRetryable { + + @SuppressWarnings("DuplicatedCode") + @Nonnull + @Override + public Optional attempt( + @Nonnull CommitterState state, + @Nonnull Supplier> refObjSupplier) + throws CommitException { + var refObj = refObjSupplier.get(); + var byName = + refObj + .map(CatalogStateObj::nameToObjRef) + .map(c -> c.asUpdatableIndex(state.persistence(), OBJ_REF_SERIALIZER)) + .orElseGet(() -> newUpdatableIndex(state.persistence(), OBJ_REF_SERIALIZER)); + var byId = + refObj + .map(CatalogStateObj::stableIdToName) + .map(c -> c.asUpdatableIndex(state.persistence(), INDEX_KEY_SERIALIZER)) + .orElseGet(() -> newUpdatableIndex(state.persistence(), INDEX_KEY_SERIALIZER)); + var locations = + refObj + .flatMap(CatalogStateObj::locations) + .map(c -> c.asUpdatableIndex(state.persistence(), LONG_VALUES_SERIALIZER)) + .orElseGet(() -> newUpdatableIndex(state.persistence(), LONG_VALUES_SERIALIZER)); + // 'changes' contains the changes for the particular commit + var changes = newUpdatableIndex(state.persistence(), CHANGE_SERIALIZER); + + var ref = CatalogStateObj.builder(); + refObj.ifPresent(ref::from); + + var r = changeCommitter.change(state, ref, byName, byId, changes, locations); + + if (r instanceof ChangeResult.CommitChange(RESULT result)) { + ref.changes(changes.toIndexed("idx-changes-", state::writeOrReplace)) + .nameToObjRef(byName.toIndexed("idx-name-", state::writeOrReplace)) + .stableIdToName(byId.toIndexed("idx-id-", state::writeOrReplace)) + .locations(locations.toOptionalIndexed("idx-loc-", state::writeOrReplace)); + return state.commitResult(result, ref, refObj); + } + if (r instanceof ChangeResult.NoChange(RESULT result)) { + return state.noCommit(result); + } + throw new IllegalStateException("" + r); + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/ChangeCommitter.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/ChangeCommitter.java new file mode 100644 index 0000000000..8ec54a8af3 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/ChangeCommitter.java @@ -0,0 +1,38 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import jakarta.annotation.Nonnull; +import org.apache.polaris.persistence.nosql.api.commit.CommitException; +import org.apache.polaris.persistence.nosql.api.commit.CommitterState; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.index.UpdatableIndex; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; + +@FunctionalInterface +interface ChangeCommitter { + @Nonnull + ChangeResult change( + @Nonnull CommitterState state, + @Nonnull ContainerObj.Builder ref, + @Nonnull UpdatableIndex byName, + @Nonnull UpdatableIndex byId) + throws CommitException; +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/ChangeCommitterWrapper.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/ChangeCommitterWrapper.java new file mode 100644 index 0000000000..3adbc881a9 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/ChangeCommitterWrapper.java @@ -0,0 +1,82 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static org.apache.polaris.persistence.nosql.api.index.IndexContainer.newUpdatableIndex; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.INDEX_KEY_SERIALIZER; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.newContainerBuilderForEntityType; + +import jakarta.annotation.Nonnull; +import java.util.Optional; +import java.util.function.Supplier; +import org.apache.polaris.core.entity.PolarisEntityType; +import org.apache.polaris.persistence.nosql.api.commit.CommitException; +import org.apache.polaris.persistence.nosql.api.commit.CommitRetryable; +import org.apache.polaris.persistence.nosql.api.commit.CommitterState; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; + +/** + * Abstracts common {@link ContainerObj#stableIdToName()} and {@link ContainerObj#nameToObjRef()} + * handling for committing operations, for non-catalog related types. + * + * @param commited object type + * @param builder type for {@link REF_OBJ} + * @param result of the commiting operation + */ +record ChangeCommitterWrapper< + REF_OBJ extends ContainerObj, B extends ContainerObj.Builder, RESULT>( + ChangeCommitter changeCommitter, PolarisEntityType entityType) + implements CommitRetryable { + + @SuppressWarnings("DuplicatedCode") + @Nonnull + @Override + public Optional attempt( + @Nonnull CommitterState state, + @Nonnull Supplier> refObjSupplier) + throws CommitException { + var refObj = refObjSupplier.get(); + var byName = + refObj + .map(ContainerObj::nameToObjRef) + .map(c -> c.asUpdatableIndex(state.persistence(), OBJ_REF_SERIALIZER)) + .orElseGet(() -> newUpdatableIndex(state.persistence(), OBJ_REF_SERIALIZER)); + var byId = + refObj + .map(ContainerObj::stableIdToName) + .map(c -> c.asUpdatableIndex(state.persistence(), INDEX_KEY_SERIALIZER)) + .orElseGet(() -> newUpdatableIndex(state.persistence(), INDEX_KEY_SERIALIZER)); + @SuppressWarnings("unchecked") + var ref = (B) newContainerBuilderForEntityType(entityType); + refObj.ifPresent(ref::from); + + var r = changeCommitter.change(state, ref, byName, byId); + + if (r instanceof ChangeResult.CommitChange(RESULT result)) { + ref.nameToObjRef(byName.toIndexed("idx-name-", state::writeOrReplace)) + .stableIdToName(byId.toIndexed("idx-id-", state::writeOrReplace)); + return state.commitResult(result, ref, refObj); + } + if (r instanceof ChangeResult.NoChange(RESULT result)) { + return state.noCommit(result); + } + throw new IllegalStateException("" + r); + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/ChangeResult.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/ChangeResult.java new file mode 100644 index 0000000000..47a46f4fde --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/ChangeResult.java @@ -0,0 +1,28 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +interface ChangeResult { + @SuppressWarnings("unused") + RESULT result(); + + record CommitChange(RESULT result) implements ChangeResult {} + + record NoChange(RESULT result) implements ChangeResult {} +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/Concern.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/Concern.java new file mode 100644 index 0000000000..491ca35451 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/Concern.java @@ -0,0 +1,38 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static com.google.common.base.Preconditions.checkArgument; + +import jakarta.annotation.Nonnull; +import org.apache.polaris.core.entity.PolarisEntityCore; +import org.apache.polaris.core.entity.PolarisEntityType; + +record Concern(@Nonnull PolarisEntityType entityType, long catalogId, boolean catalogContent) { + static Concern forEntity(PolarisEntityCore entity) { + var type = entity.getType(); + var catalogId = entity.getCatalogId(); + var catContent = TypeMapping.isCatalogContent(type); + checkArgument( + (!catContent && type != PolarisEntityType.CATALOG_ROLE) || catalogId > 0L, + "catalogId must be a positive integer for %s", + type); + return new Concern(type, catalogId, catContent); + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/EntityUpdate.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/EntityUpdate.java new file mode 100644 index 0000000000..6a21271cf4 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/EntityUpdate.java @@ -0,0 +1,33 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import org.apache.polaris.core.entity.PolarisBaseEntity; + +record EntityUpdate(Operation operation, PolarisBaseEntity entity, boolean cleanup) { + EntityUpdate(Operation operation, PolarisBaseEntity entity) { + this(operation, entity, false); + } + + enum Operation { + CREATE, + UPDATE, + DELETE + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/Grant.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/Grant.java new file mode 100644 index 0000000000..f5a530143a --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/Grant.java @@ -0,0 +1,28 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import jakarta.annotation.Nonnull; +import org.apache.polaris.core.entity.PolarisEntityCore; +import org.apache.polaris.core.entity.PolarisPrivilege; + +record Grant( + @Nonnull PolarisEntityCore securable, + @Nonnull PolarisEntityCore grantee, + @Nonnull PolarisPrivilege privilege) {} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/GrantTriplet.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/GrantTriplet.java new file mode 100644 index 0000000000..370dcd6ebb --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/GrantTriplet.java @@ -0,0 +1,57 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static com.google.common.base.Preconditions.checkArgument; + +import org.apache.polaris.core.entity.PolarisEntityCore; + +/** + * Represents the triplet of catalog-ID, entity-ID and type-code plus a reverse-or-key marker. + * String representations of this type are used as ACL names and "role" names. + */ +record GrantTriplet(boolean reverseOrKey, long catalogId, long id, int typeCode) { + static GrantTriplet fromRoleName(String roleName) { + var c0 = roleName.charAt(0); + checkArgument(roleName.charAt(1) == '/' && (c0 == 'r' || c0 == 'd')); + + var idx2 = roleName.indexOf('/', 2); + var idx3 = roleName.indexOf('/', idx2 + 1); + + var catalogId = Long.parseLong(roleName.substring(2, idx2)); + var id = Long.parseLong(roleName.substring(idx2 + 1, idx3)); + var typeCode = Integer.parseInt(roleName.substring(idx3 + 1)); + + var reversed = c0 == 'r'; + + return new GrantTriplet(reversed, catalogId, id, typeCode); + } + + static GrantTriplet forEntity(PolarisEntityCore entity) { + return new GrantTriplet(true, entity.getCatalogId(), entity.getId(), entity.getTypeCode()); + } + + GrantTriplet asDirected() { + return new GrantTriplet(false, catalogId, id, typeCode); + } + + String toRoleName() { + return (reverseOrKey ? "r/" : "d/") + catalogId + "/" + id + "/" + typeCode; + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/Identifier.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/Identifier.java new file mode 100644 index 0000000000..52fd6110ad --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/Identifier.java @@ -0,0 +1,153 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.ArrayList; +import java.util.List; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.immutables.value.Value; + +@Value.Style(underrideToString = "asDotDelimitedString") +@PolarisImmutable +public interface Identifier { + @Value.Parameter + @JsonValue + List elements(); + + static Identifier identifier(List elements) { + return ImmutableIdentifier.of(elements); + } + + static Identifier identifier(String[] namespace, String name) { + return ImmutableIdentifier.builder().addElements(namespace).addElements(name).build(); + } + + static Identifier identifier(String... elements) { + return ImmutableIdentifier.of(List.of(elements)); + } + + static Identifier identifierFromLocationString(String locationString) { + var builder = builder(); + var len = locationString.length(); + var off = -1; + for (var i = 0; i < len; i++) { + var c = locationString.charAt(i); + checkArgument(c >= ' ', "Control characters are forbidden in locations"); + if (c == '/' || c == '\\') { + if (off != -1) { + builder.addElements(locationString.substring(off, i)); + off = -1; + } + } else { + if (off == -1) { + off = i; + } + } + } + if (off != -1) { + builder.addElements(locationString.substring(off)); + } + return builder.build(); + } + + default Identifier parent() { + var elems = elements(); + checkState(!elems.isEmpty(), "Empty namespace has no parent"); + return ImmutableIdentifier.of(elems.subList(0, elems.size() - 1)); + } + + default boolean isEmpty() { + return elements().isEmpty(); + } + + default int length() { + return elements().size(); + } + + default String leafName() { + var elems = elements(); + return elems.isEmpty() ? "" : elems.getLast(); + } + + default Identifier childOf(String childName) { + var elems = elements(); + var newElements = new ArrayList(elems.size() + 1); + newElements.addAll(elems); + newElements.add(childName); + return ImmutableIdentifier.of(newElements); + } + + default String asDotDelimitedString() { + return String.join(".", elements()); + } + + static ImmutableIdentifier.Builder builder() { + return ImmutableIdentifier.builder(); + } + + default IndexKey toIndexKey() { + return IndexKey.key(String.join("\u0000", elements())); + } + + default boolean startsWith(Identifier other) { + var elems = elements(); + var otherElems = other.elements(); + var otherSize = otherElems.size(); + if (otherSize > elems.size()) { + return false; + } + for (int i = 0; i < otherSize; i++) { + if (!elems.get(i).equals(otherElems.get(i))) { + return false; + } + } + return true; + } + + @CanIgnoreReturnValue + static ImmutableIdentifier.Builder indexKeyToIdentifierBuilder( + IndexKey indexKey, ImmutableIdentifier.Builder builder) { + var str = indexKey.toString(); + var l = str.length(); + for (var i = 0; i < l; ) { + var iNull = str.indexOf(0, i); + if (iNull == -1) { + builder.addElements(str.substring(i)); + return builder; + } + builder.addElements(str.substring(i, iNull)); + i = iNull + 1; + } + return builder; + } + + static ImmutableIdentifier.Builder indexKeyToIdentifierBuilder(IndexKey indexKey) { + return indexKeyToIdentifierBuilder(indexKey, ImmutableIdentifier.builder()); + } + + static Identifier indexKeyToIdentifier(IndexKey indexKey) { + return indexKeyToIdentifierBuilder(indexKey).build(); + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/IndexedContainerAccess.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/IndexedContainerAccess.java new file mode 100644 index 0000000000..609b6c5387 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/IndexedContainerAccess.java @@ -0,0 +1,482 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static org.apache.polaris.core.entity.PolarisEntityConstants.getRootContainerName; +import static org.apache.polaris.core.entity.PolarisEntityConstants.getRootEntityId; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.INDEX_KEY_SERIALIZER; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.coretypes.refs.References.perCatalogReferenceName; +import static org.apache.polaris.persistence.nosql.metastore.Identifier.indexKeyToIdentifierBuilder; + +import com.google.common.base.Suppliers; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; +import org.apache.polaris.core.entity.PolarisEntityType; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.index.Index; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; +import org.apache.polaris.persistence.nosql.coretypes.ObjBase; +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.principals.PrincipalRolesObj; +import org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalsObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.ImmediateTasksObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.RootObj; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Helper class to access the indexes on a {@link ContainerObj}. */ +abstract class IndexedContainerAccess { + protected final Persistence persistence; + + IndexedContainerAccess(Persistence persistence) { + this.persistence = persistence; + } + + static IndexedContainerAccess indexedAccessDirect( + Persistence persistence, ObjRef containerObjRef) { + return new IndexedContainerAccessImpl<>(persistence, ContainerObj.class, containerObjRef); + } + + static IndexedContainerAccess indexedAccessForCatalog( + Persistence persistence, long catalogStableId) { + return new IndexedContainerAccessImpl<>( + persistence, + perCatalogReferenceName(CatalogStateObj.CATALOG_STATE_REF_NAME_PATTERN, catalogStableId), + CatalogStateObj.class, + catalogStableId); + } + + static IndexedContainerAccess indexedAccessForEntityType( + int entityTypeCode, Persistence persistence, long catalogStableId) { + return indexedAccessForEntityType( + TypeMapping.typeFromCode(entityTypeCode), persistence, catalogStableId); + } + + static IndexedContainerAccess indexedAccessForEntityType( + PolarisEntityType entityType, Persistence persistence, long catalogStableId) { + var refName = TypeMapping.referenceName(entityType, catalogStableId); + var access = + switch (entityType) { + case CATALOG -> + // This one is special - if catalogStableId is present, return the indexed-access to + // the catalog _content_, otherwise return the index-access to the catalogs. + catalogStableId != 0L + ? new IndexedContainerAccessImpl<>( + persistence, refName, CatalogStateObj.class, catalogStableId) + : new IndexedContainerAccessImpl<>(persistence, refName, CatalogsObj.class, 0L); + case PRINCIPAL -> { + if (catalogStableId != 0L) { + yield new IndexedContainerAccessEmpty<>(persistence); + } + yield new IndexedContainerAccessImpl<>( + persistence, refName, PrincipalsObj.class, catalogStableId); + } + case PRINCIPAL_ROLE -> { + if (catalogStableId != 0L) { + yield new IndexedContainerAccessEmpty<>(persistence); + } + yield new IndexedContainerAccessImpl<>( + persistence, refName, PrincipalRolesObj.class, catalogStableId); + } + case TASK -> { + if (catalogStableId != 0L) { + yield new IndexedContainerAccessEmpty<>(persistence); + } + yield new IndexedContainerAccessImpl<>( + persistence, refName, ImmediateTasksObj.class, catalogStableId); + } + case ROOT -> { + if (catalogStableId != 0L) { + yield new IndexedContainerAccessEmpty<>(persistence); + } + yield new IndexedContainerAccessRoot<>(persistence); + } + + // per catalog + case CATALOG_ROLE -> { + if (catalogStableId == 0L) { + yield new IndexedContainerAccessEmpty<>(persistence); + } + yield new IndexedContainerAccessImpl<>( + persistence, refName, CatalogRolesObj.class, catalogStableId); + } + case NAMESPACE, TABLE_LIKE, POLICY -> { + if (catalogStableId == 0L) { + yield new IndexedContainerAccessEmpty<>(persistence); + } + yield new IndexedContainerAccessImpl<>( + persistence, refName, CatalogStateObj.class, catalogStableId); + } + + default -> throw new IllegalArgumentException("Unsupported entity type: " + entityType); + }; + + @SuppressWarnings("unchecked") + var r = (IndexedContainerAccess) access; + return r; + } + + abstract Optional refObj(); + + abstract Optional byId(long stableId); + + abstract Optional nameKeyById(long stableId); + + abstract Optional byIdentifier(Identifier identifier); + + abstract Optional byParentIdAndName(long parentId, String name); + + abstract Optional byNameOnRoot(String name); + + abstract Optional> nameIndex(); + + abstract Optional> stableIdIndex(); + + abstract long catalogStableId(); + + private static final class IndexedContainerAccessRoot + extends IndexedContainerAccess { + private static final IndexKey nameKey = IndexKey.key(getRootContainerName()); + private static final IndexKey idKey = IndexKey.key(getRootEntityId()); + + private Optional root; + + IndexedContainerAccessRoot(Persistence persistence) { + super(persistence); + } + + @SuppressWarnings("OptionalAssignedToNull") + private Optional rootLazy() { + if (root == null) { + root = persistence.fetchReferenceHead(RootObj.ROOT_REF_NAME, ObjBase.class); + } + return root; + } + + @Override + long catalogStableId() { + return 0L; + } + + @Override + Optional> stableIdIndex() { + return Optional.of(new SingletonIndex<>(idKey, () -> nameKey)); + } + + @Override + Optional> nameIndex() { + return Optional.of( + new SingletonIndex<>(nameKey, () -> rootLazy().map(ObjRef::objRef).orElse(null))); + } + + @Override + Optional byIdentifier(Identifier identifier) { + if (identifier.elements().equals(List.of(getRootContainerName()))) { + return root; + } + return Optional.empty(); + } + + @Override + Optional byNameOnRoot(String name) { + if (name.equals(getRootContainerName())) { + return root; + } + return Optional.empty(); + } + + @Override + Optional byParentIdAndName(long parentId, String name) { + if (parentId == 0L) { + return byNameOnRoot(name); + } + return Optional.empty(); + } + + @Override + Optional nameKeyById(long stableId) { + return stableId == 0L ? Optional.of(nameKey) : Optional.empty(); + } + + @Override + Optional byId(long stableId) { + if (stableId == 0L) { + return rootLazy(); + } + return Optional.empty(); + } + + @Override + Optional refObj() { + throw new UnsupportedOperationException(); + } + + static final class SingletonIndex implements Index { + private final IndexKey key; + private final Supplier valueSupplier; + private volatile T value; + + SingletonIndex(IndexKey key, Supplier value) { + this.key = key; + this.valueSupplier = value; + } + + @Override + public void prefetchIfNecessary(Iterable keys) {} + + @Override + public boolean contains(IndexKey key) { + return this.key.equals(key); + } + + private T value() { + var v = value; + if (v == null) { + value = v = valueSupplier.get(); + } + return v; + } + + @Nullable + @Override + public T get(@Nonnull IndexKey key) { + return this.key.equals(key) ? value() : null; + } + + @Override + @Nonnull + public Iterator> iterator() { + return Collections.singletonList(Map.entry(key, value())).iterator(); + } + + @Nonnull + @Override + public Iterator> iterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + // TODO this is technically incorrect + return iterator(); + } + + @Nonnull + @Override + public Iterator> reverseIterator() { + return iterator(); + } + + @Nonnull + @Override + public Iterator> reverseIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + // TODO this is technically incorrect + return reverseIterator(); + } + } + } + + private static final class IndexedContainerAccessEmpty + extends IndexedContainerAccess { + IndexedContainerAccessEmpty(Persistence persistence) { + super(persistence); + } + + @Override + Optional refObj() { + return Optional.empty(); + } + + @Override + Optional byId(long stableId) { + return Optional.empty(); + } + + @Override + Optional nameKeyById(long stableId) { + return Optional.empty(); + } + + @Override + Optional byIdentifier(Identifier identifier) { + return Optional.empty(); + } + + @Override + Optional byParentIdAndName(long parentId, String name) { + return Optional.empty(); + } + + @Override + Optional byNameOnRoot(String name) { + return Optional.empty(); + } + + @Override + Optional> nameIndex() { + return Optional.empty(); + } + + @Override + Optional> stableIdIndex() { + return Optional.empty(); + } + + @Override + long catalogStableId() { + return 0; + } + } + + private static final class IndexedContainerAccessImpl + extends IndexedContainerAccess { + private static final Logger LOGGER = LoggerFactory.getLogger(IndexedContainerAccessImpl.class); + + private final ObjRef containerObjRef; + private final String referenceName; + private final Class referenceObjType; + private final long catalogStableId; + private Optional refObj; + private final Supplier>> nameIndexSupplier = + Suppliers.memoize(this::supplyNameIndex); + private final Supplier>> idIndexSupplier = + Suppliers.memoize(this::supplyIdIndex); + + private IndexedContainerAccessImpl( + Persistence persistence, + String referenceName, + Class referenceObjType, + long catalogStableId) { + super(persistence); + this.referenceName = referenceName; + this.referenceObjType = referenceObjType; + this.catalogStableId = catalogStableId; + this.containerObjRef = null; + } + + public IndexedContainerAccessImpl( + Persistence persistence, Class containerObjClass, ObjRef containerObjId) { + super(persistence); + this.referenceName = null; + this.referenceObjType = containerObjClass; + this.containerObjRef = containerObjId; + this.catalogStableId = -1; + } + + @SuppressWarnings("OptionalAssignedToNull") + @Override + Optional refObj() { + if (this.refObj == null) { + if (referenceName != null) { + this.refObj = persistence.fetchReferenceHead(referenceName, referenceObjType); + LOGGER.debug("Fetched head {} for reference '{}'", refObj, referenceName); + } else if (containerObjRef != null) { + this.refObj = Optional.ofNullable(persistence.fetch(containerObjRef, referenceObjType)); + } else { + // Should really never ever happen + throw new IllegalStateException(); + } + } + return this.refObj; + } + + @Override + Optional byId(long stableId) { + return objRefById(stableId).flatMap(this::objByRef); + } + + @Override + Optional nameKeyById(long stableId) { + return stableIdIndex() + .flatMap( + idIndex -> { + var nameKey = idIndex.get(IndexKey.key(stableId)); + return Optional.ofNullable(nameKey); + }); + } + + @Override + Optional byIdentifier(Identifier identifier) { + return objRefByName(identifier.toIndexKey()).flatMap(this::objByRef); + } + + @Override + Optional byParentIdAndName(long parentId, String name) { + return (parentId != 0L + ? nameKeyById(parentId) + .flatMap( + parentKey -> { + var fullIdentifier = + indexKeyToIdentifierBuilder(parentKey).addElements(name).build(); + return objRefByName(fullIdentifier.toIndexKey()); + }) + : objRefByName(IndexKey.key(name))) + .flatMap(this::objByRef); + } + + @Override + Optional byNameOnRoot(String name) { + return objRefByName(IndexKey.key(name)).flatMap(this::objByRef); + } + + @Override + Optional> nameIndex() { + return nameIndexSupplier.get(); + } + + @Override + Optional> stableIdIndex() { + return idIndexSupplier.get(); + } + + @Override + long catalogStableId() { + return catalogStableId; + } + + private Optional objRefById(long stableId) { + return nameKeyById(stableId).flatMap(this::objRefByName); + } + + private Optional objByRef(ObjRef objRef) { + return Optional.ofNullable(persistence.fetch(objRef, ObjBase.class)); + } + + private Optional objRefByName(IndexKey nameKey) { + return nameIndex().flatMap(nameIdx -> Optional.ofNullable(nameIdx.get(nameKey))); + } + + private Optional> supplyNameIndex() { + return refObj().map(ref -> ref.nameToObjRef().indexForRead(persistence, OBJ_REF_SERIALIZER)); + } + + private Optional> supplyIdIndex() { + return refObj() + .map(ref -> ref.stableIdToName().indexForRead(persistence, INDEX_KEY_SERIALIZER)); + } + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/MemoizedIndexedAccess.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/MemoizedIndexedAccess.java new file mode 100644 index 0000000000..df0499c968 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/MemoizedIndexedAccess.java @@ -0,0 +1,120 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static com.google.common.base.Preconditions.checkArgument; +import static org.apache.polaris.persistence.nosql.metastore.IndexedContainerAccess.indexedAccessForCatalog; +import static org.apache.polaris.persistence.nosql.metastore.IndexedContainerAccess.indexedAccessForEntityType; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.isCatalogContent; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.polaris.core.entity.PolarisEntityType; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.index.Index; +import org.apache.polaris.persistence.nosql.api.obj.BaseCommitObj; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.ref.Reference; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; +import org.apache.polaris.persistence.nosql.coretypes.ObjBase; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogStateObj; + +/** + * Memoizes {@link IndexedContainerAccess} instances for a {@link PersistenceMetaStore} instance. + * + *

Memoizing these instances avoids unnecessary {@link Reference} lookups and index + * deserialization, even if backed by the persistence cache. Committing functions must + * always call the appropriate {@code invalidate*()} functions. + */ +final class MemoizedIndexedAccess { + private final Persistence persistence; + + /** + * Memoizes objects already accessed by the holding {@link PersistenceMetaStore} instance. + * + *

The {@link Index} instances held via this map are thread-safe + */ + private final Map> map = new ConcurrentHashMap<>(); + + private final Map> grantsHeads = new ConcurrentHashMap<>(); + + private record Key(long catalogId, int entityTypeCode, boolean catalogContent) {} + + static MemoizedIndexedAccess newMemoizedIndexedAccess(Persistence persistence) { + return new MemoizedIndexedAccess(persistence); + } + + private MemoizedIndexedAccess(Persistence persistence) { + this.persistence = persistence; + } + + IndexedContainerAccess indexedAccess( + long catalogId, int entityTypeCode) { + if (isCatalogContent(entityTypeCode)) { + @SuppressWarnings("unchecked") + var r = (IndexedContainerAccess) catalogContent(catalogId); + return r; + } + var key = new Key(catalogId, entityTypeCode, false); + var access = + map.computeIfAbsent( + key, k -> indexedAccessForEntityType(k.entityTypeCode, persistence, k.catalogId)); + @SuppressWarnings("unchecked") + var r = (IndexedContainerAccess) access; + return r; + } + + IndexedContainerAccess indexedAccessDirect(ObjRef containerObjRef) { + return IndexedContainerAccess.indexedAccessDirect(persistence, containerObjRef); + } + + IndexedContainerAccess catalogContent(long catalogId) { + checkArgument(catalogId != 0L && catalogId != -1L, "invalid catalogId"); + var key = new Key(catalogId, PolarisEntityType.CATALOG.getCode(), true); + var access = map.computeIfAbsent(key, k -> indexedAccessForCatalog(persistence, catalogId)); + @SuppressWarnings("unchecked") + var r = (IndexedContainerAccess) access; + return r; + } + + Optional referenceHead(String refName, Class type) { + return cast( + grantsHeads.computeIfAbsent(refName, r -> cast(persistence.fetchReferenceHead(r, type)))); + } + + void invalidateCatalogContent(long catalogId) { + var key = new Key(catalogId, PolarisEntityType.CATALOG.getCode(), true); + map.remove(key); + } + + void invalidateIndexedAccess(long catalogId, int entityTypeCode) { + var key = new Key(catalogId, entityTypeCode, false); + map.remove(key); + } + + void invalidateReferenceHead(String refName) { + grantsHeads.remove(refName); + } + + @SuppressWarnings("unchecked") + private static R cast(Object o) { + return (R) o; + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/MutationResults.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/MutationResults.java new file mode 100644 index 0000000000..fcfc78084b --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/MutationResults.java @@ -0,0 +1,147 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static org.apache.polaris.core.persistence.dao.entity.BaseResult.ReturnStatus.SUCCESS; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.core.persistence.dao.entity.BaseResult; +import org.apache.polaris.core.persistence.dao.entity.DropEntityResult; +import org.apache.polaris.core.persistence.dao.entity.EntityResult; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; + +final class MutationResults { + private final List results; + // TODO populate and process 'aclsToRemove' + private final List aclsToRemove; + private final List droppedEntities; + private final List policyIndexKeysToRemove = new ArrayList<>(); + + boolean anyChange; + boolean hardFailure; + + private MutationResults( + List results, + List aclsToRemove, + List droppedEntities) { + this.results = results; + this.aclsToRemove = aclsToRemove; + this.droppedEntities = droppedEntities; + } + + MutationResults(BaseResult single) { + this(List.of(single), List.of(), List.of()); + } + + static MutationResults singleEntityResult(BaseResult.ReturnStatus returnStatus) { + return new MutationResults(new EntityResult(returnStatus, null)); + } + + static MutationResults singleEntityResult(PolarisBaseEntity entity) { + return new MutationResults(new EntityResult(entity)); + } + + static MutationResults newMutableMutationResults() { + return new MutationResults(new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); + } + + List results() { + return results; + } + + List aclsToRemove() { + return aclsToRemove; + } + + List droppedEntities() { + return droppedEntities; + } + + List policyIndexKeysToRemove() { + return policyIndexKeysToRemove; + } + + void addPolicyIndexKeyToRemove(IndexKey indexKey) { + policyIndexKeysToRemove.add(indexKey); + } + + void entityResult(PolarisBaseEntity entity) { + add(new EntityResult(entity)); + anyChange = true; + } + + void entityResultNoChange(PolarisBaseEntity entity) { + add(new EntityResult(entity)); + } + + void unchangedEntityResult(PolarisBaseEntity entity) { + add(new EntityResult(entity)); + } + + void entityResult(BaseResult.ReturnStatus returnStatus) { + entityResult(returnStatus, null); + } + + void entityResult(BaseResult.ReturnStatus returnStatus, String extraInformation) { + add(new EntityResult(returnStatus, extraInformation)); + hardFailure |= returnStatus != SUCCESS; + } + + void dropResult(PolarisBaseEntity entity) { + add(new DropEntityResult()); + droppedEntities.add(entity); + anyChange = true; + } + + void dropResult(BaseResult.ReturnStatus returnStatus) { + dropResult(returnStatus, null); + } + + void dropResult(BaseResult.ReturnStatus returnStatus, String extraInformation) { + add(new DropEntityResult(returnStatus, extraInformation)); + hardFailure |= returnStatus != SUCCESS; + } + + private void add(BaseResult result) { + results.add(result); + hardFailure |= result.getReturnStatus() != SUCCESS; + } + + String failuresAsString() { + if (!hardFailure) { + return "(none)"; + } + + return results.stream() + .filter(result -> result.getReturnStatus() != SUCCESS) + .map(result -> result.getReturnStatus().name()) + .collect(Collectors.joining(", ")); + } + + public Optional firstFailure() { + if (!hardFailure) { + return Optional.empty(); + } + return results.stream().filter(result -> result.getReturnStatus() != SUCCESS).findFirst(); + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PersistenceMetaStore.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PersistenceMetaStore.java new file mode 100644 index 0000000000..787e0962a4 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PersistenceMetaStore.java @@ -0,0 +1,2664 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static java.lang.String.format; +import static java.util.Objects.requireNonNull; +import static org.apache.polaris.core.entity.PolarisEntityConstants.ENTITY_BASE_LOCATION; +import static org.apache.polaris.core.persistence.dao.entity.BaseResult.ReturnStatus.POLICY_MAPPING_OF_SAME_TYPE_ALREADY_EXISTS; +import static org.apache.polaris.persistence.nosql.api.index.IndexContainer.newUpdatableIndex; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.INDEX_KEY_SERIALIZER; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.apache.polaris.persistence.nosql.coretypes.catalog.LongValues.LONG_VALUES_SERIALIZER; +import static org.apache.polaris.persistence.nosql.coretypes.catalog.LongValues.longValues; +import static org.apache.polaris.persistence.nosql.coretypes.realm.PolicyMapping.POLICY_MAPPING_SERIALIZER; +import static org.apache.polaris.persistence.nosql.coretypes.realm.PolicyMappingsObj.POLICY_MAPPINGS_REF_NAME; +import static org.apache.polaris.persistence.nosql.coretypes.realm.RealmGrantsObj.REALM_GRANTS_REF_NAME; +import static org.apache.polaris.persistence.nosql.coretypes.refs.References.catalogReferenceNames; +import static org.apache.polaris.persistence.nosql.coretypes.refs.References.perCatalogReferenceName; +import static org.apache.polaris.persistence.nosql.metastore.Identifier.identifierFromLocationString; +import static org.apache.polaris.persistence.nosql.metastore.Identifier.indexKeyToIdentifier; +import static org.apache.polaris.persistence.nosql.metastore.Identifier.indexKeyToIdentifierBuilder; +import static org.apache.polaris.persistence.nosql.metastore.MemoizedIndexedAccess.newMemoizedIndexedAccess; +import static org.apache.polaris.persistence.nosql.metastore.MutationResults.newMutableMutationResults; +import static org.apache.polaris.persistence.nosql.metastore.MutationResults.singleEntityResult; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.containerTypeForEntityType; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.entitySubTypeCodeFromObjType; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.filterIsEntityType; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.isCatalogContent; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.mapToEntity; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.mapToObj; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.maybeObjToPolarisPrincipalSecrets; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.objTypeForPolarisType; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.objTypeForPolarisTypeForFiltering; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.principalObjToPolarisPrincipalSecrets; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.referenceName; + +import com.google.common.collect.Streams; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.polaris.core.PolarisCallContext; +import org.apache.polaris.core.PolarisDiagnostics; +import org.apache.polaris.core.entity.AsyncTaskType; +import org.apache.polaris.core.entity.EntityNameLookupRecord; +import org.apache.polaris.core.entity.LocationBasedEntity; +import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.core.entity.PolarisChangeTrackingVersions; +import org.apache.polaris.core.entity.PolarisEntity; +import org.apache.polaris.core.entity.PolarisEntityConstants; +import org.apache.polaris.core.entity.PolarisEntityCore; +import org.apache.polaris.core.entity.PolarisEntityId; +import org.apache.polaris.core.entity.PolarisEntitySubType; +import org.apache.polaris.core.entity.PolarisEntityType; +import org.apache.polaris.core.entity.PolarisEvent; +import org.apache.polaris.core.entity.PolarisGrantRecord; +import org.apache.polaris.core.entity.PolarisPrincipalSecrets; +import org.apache.polaris.core.entity.PolarisPrivilege; +import org.apache.polaris.core.entity.PolarisTaskConstants; +import org.apache.polaris.core.persistence.BaseMetaStoreManager; +import org.apache.polaris.core.persistence.BasePersistence; +import org.apache.polaris.core.persistence.IntegrationPersistence; +import org.apache.polaris.core.persistence.PolarisObjectMapperUtil; +import org.apache.polaris.core.persistence.PrincipalSecretsGenerator; +import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet; +import org.apache.polaris.core.persistence.dao.entity.BaseResult; +import org.apache.polaris.core.persistence.dao.entity.CreateCatalogResult; +import org.apache.polaris.core.persistence.dao.entity.CreatePrincipalResult; +import org.apache.polaris.core.persistence.dao.entity.DropEntityResult; +import org.apache.polaris.core.persistence.dao.entity.EntitiesResult; +import org.apache.polaris.core.persistence.dao.entity.EntityResult; +import org.apache.polaris.core.persistence.dao.entity.EntityWithPath; +import org.apache.polaris.core.persistence.dao.entity.LoadGrantsResult; +import org.apache.polaris.core.persistence.dao.entity.LoadPolicyMappingsResult; +import org.apache.polaris.core.persistence.dao.entity.PolicyAttachmentResult; +import org.apache.polaris.core.persistence.pagination.Page; +import org.apache.polaris.core.persistence.pagination.PageToken; +import org.apache.polaris.core.policy.PolarisPolicyMappingRecord; +import org.apache.polaris.core.policy.PolicyMappingUtil; +import org.apache.polaris.core.policy.PolicyType; +import org.apache.polaris.core.storage.PolarisStorageConfigurationInfo; +import org.apache.polaris.core.storage.PolarisStorageIntegration; +import org.apache.polaris.core.storage.PolarisStorageIntegrationProvider; +import org.apache.polaris.core.storage.StorageLocation; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.commit.CommitException; +import org.apache.polaris.persistence.nosql.api.commit.CommitRetryable; +import org.apache.polaris.persistence.nosql.api.commit.CommitterState; +import org.apache.polaris.persistence.nosql.api.index.Index; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.index.UpdatableIndex; +import org.apache.polaris.persistence.nosql.api.obj.BaseCommitObj; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjTypes; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; +import org.apache.polaris.persistence.nosql.coretypes.ObjBase; +import org.apache.polaris.persistence.nosql.coretypes.acl.AclObj; +import org.apache.polaris.persistence.nosql.coretypes.acl.GrantsObj; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogObj; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogRoleObj; +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.LongValues; +import org.apache.polaris.persistence.nosql.coretypes.changes.Change; +import org.apache.polaris.persistence.nosql.coretypes.changes.ChangeAdd; +import org.apache.polaris.persistence.nosql.coretypes.changes.ChangeRemove; +import org.apache.polaris.persistence.nosql.coretypes.changes.ChangeRename; +import org.apache.polaris.persistence.nosql.coretypes.changes.ChangeUpdate; +import org.apache.polaris.persistence.nosql.coretypes.content.ContentObj; +import org.apache.polaris.persistence.nosql.coretypes.content.PolicyObj; +import org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalObj; +import org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalsObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.PolicyMapping; +import org.apache.polaris.persistence.nosql.coretypes.realm.PolicyMappingsObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.RealmGrantsObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.RootObj; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class PersistenceMetaStore implements BasePersistence, IntegrationPersistence { + private static final Logger LOGGER = LoggerFactory.getLogger(PersistenceMetaStore.class); + + private final Persistence persistence; + private final Privileges privileges; + private final PolarisStorageIntegrationProvider storageIntegrationProvider; + private final MemoizedIndexedAccess memoizedIndexedAccess; + private final PolarisDiagnostics diagnostics; + + PersistenceMetaStore( + Persistence persistence, + Privileges privileges, + PolarisStorageIntegrationProvider storageIntegrationProvider, + PolarisDiagnostics diagnostics) { + this.persistence = persistence; + this.privileges = privileges; + this.storageIntegrationProvider = storageIntegrationProvider; + this.memoizedIndexedAccess = newMemoizedIndexedAccess(persistence); + this.diagnostics = diagnostics; + } + + private RESULT performPrincipalChange( + @Nonnull Class resultType, + @Nonnull PrincipalsChangeCommitter commitRetryable) { + try { + return persistence + .createCommitter(PrincipalsObj.PRINCIPALS_REF_NAME, PrincipalsObj.class, resultType) + .synchronizingLocally() + .commitRuntimeException(new PrincipalsChangeCommitterWrapper<>(commitRetryable)) + .orElseThrow(); + } finally { + memoizedIndexedAccess.invalidateIndexedAccess(0L, PolarisEntityType.PRINCIPAL.getCode()); + } + } + + RESULT performChange( + @Nonnull PolarisEntityType entityType, + @Nonnull Class referencedObjType, + @Nonnull Class resultType, + long catalogStableId, + @Nonnull ChangeCommitter changeCommitter) { + try { + var committer = + persistence + .createCommitter( + referenceName(entityType, catalogStableId), referencedObjType, resultType) + .synchronizingLocally(); + var commitRetryable = new ChangeCommitterWrapper<>(changeCommitter, entityType); + return committer.commitRuntimeException(commitRetryable).orElseThrow(); + } finally { + memoizedIndexedAccess.invalidateIndexedAccess(catalogStableId, entityType.getCode()); + } + } + + @Override + public long generateNewId(@Nonnull PolarisCallContext callCtx) { + return generateNewId(); + } + + long generateNewId() { + return persistence.generateId(); + } + + void initializeCatalogsIfNecessary() { + memoizedIndexedAccess + .indexedAccess(0, PolarisEntityType.CATALOG.getCode()) + .nameIndex() + .ifPresent( + names -> + persistence + .bucketizedBulkFetches( + Streams.stream(names).filter(Objects::nonNull).map(Map.Entry::getValue), + CatalogObj.class) + .filter(Objects::nonNull) + .forEach( + catalogObj -> { + LOGGER.debug("Initializing catalog {} if necessary", catalogObj.name()); + initializeCatalogIfNecessary(persistence, catalogObj); + })); + } + + CreateCatalogResult createCatalog( + @Nonnull PolarisCallContext callCtx, + PolarisBaseEntity catalog, + List principalRoles, + PolarisStorageIntegration integration) { + checkArgument(catalog != null && catalog.getType() == PolarisEntityType.CATALOG); + + LOGGER.debug("create catalog #{} '{}'", catalog.getId(), catalog.getName()); + + return performChange( + PolarisEntityType.CATALOG, + CatalogsObj.class, + CreateCatalogResult.class, + 0L, + ((state, ref, byName, byId) -> { + var nameKey = IndexKey.key(catalog.getName()); + var idKey = IndexKey.key(catalog.getId()); + + // check if that catalog has already been created + var existing = byName.get(nameKey); + var persistence = state.persistence(); + var catalogObj = existing != null ? persistence.fetch(existing, CatalogObj.class) : null; + + // if found, probably a retry, simply return the previously created catalog + // TODO not sure how a "retry" could happen with the same ID though (see + // PolarisMetaStoreManagerImpl.createCatalog())... + if (catalogObj != null && catalogObj.stableId() != catalog.getId()) { + // A catalog with the same name already exists (different ID) + return new ChangeResult.NoChange<>( + new CreateCatalogResult(BaseResult.ReturnStatus.ENTITY_ALREADY_EXISTS, null)); + } + if (catalogObj == null) { + catalogObj = + TypeMapping.mapToObj(catalog, Optional.empty()) + .id(persistence.generateId()) + .build(); + state.writeOrReplace("catalog", catalogObj); + } + + initializeCatalogIfNecessary(persistence, catalogObj); + + checkState(!byId.contains(idKey), "Catalog ID %s already used", catalog.getId()); + + // 'persistStorageIntegrationIfNeeded' is a no-op in all implementations ?!?!? + persistStorageIntegrationIfNeeded(callCtx, catalog, integration); + + var catalogAdminRoleObj = + createCatalogRoleIdempotent( + catalogObj, + persistence.generateId(), + PolarisEntityConstants.getNameOfCatalogAdminRole()); + + var catalogAdminRole = mapToEntity(catalogAdminRoleObj, catalogObj.stableId()); + + var grants = new ArrayList(); + + // grant the catalog admin role access-management on the catalog + grants.add(new Grant(catalog, catalogAdminRole, PolarisPrivilege.CATALOG_MANAGE_ACCESS)); + // grant the catalog admin role metadata-management on the catalog; this one is revocable + grants.add( + new Grant(catalog, catalogAdminRole, PolarisPrivilege.CATALOG_MANAGE_METADATA)); + + var effRoles = + principalRoles.isEmpty() + ? List.of( + requireNonNull( + lookupEntityByName( + callCtx, + 0L, + 0L, + PolarisEntityType.PRINCIPAL_ROLE.getCode(), + PolarisEntityConstants.getNameOfPrincipalServiceAdminRole()))) + : principalRoles; + + for (PolarisBaseEntity effRole : effRoles) { + grants.add(new Grant(catalogAdminRole, effRole, PolarisPrivilege.CATALOG_ROLE_USAGE)); + } + + persistGrantsOrRevokes(0L, true, grants.toArray(Grant[]::new)); + + byName.put(nameKey, objRef(catalogObj)); + byId.put(idKey, nameKey); + + if (existing == null) { + // created + return new ChangeResult.CommitChange<>( + new CreateCatalogResult(catalog, catalogAdminRole)); + } + // retry + return new ChangeResult.NoChange<>( + new CreateCatalogResult(BaseResult.ReturnStatus.ENTITY_ALREADY_EXISTS, null)); + })); + } + + CatalogRoleObj createCatalogRoleIdempotent( + @Nonnull CatalogObj catalogObj, long catalogRoleStableId, @Nonnull String roleName) { + return performChange( + PolarisEntityType.CATALOG_ROLE, + CatalogRolesObj.class, + CatalogRoleObj.class, + catalogObj.stableId(), + ((state, ref, byName, byId) -> { + var nameKey = IndexKey.key(roleName); + var idKey = IndexKey.key(catalogRoleStableId); + var nameRef = byName.get(nameKey); + var persistence = state.persistence(); + + if (nameRef != null) { + var role = persistence.fetch(nameRef, CatalogRoleObj.class); + requireNonNull(role); + return new ChangeResult.NoChange<>(role); + } + + checkState(!byId.contains(idKey), "Catalog role ID %s already used", catalogRoleStableId); + + var now = persistence.currentInstant(); + var roleObj = + CatalogRoleObj.builder() + .id(persistence.generateId()) + .name(roleName) + .createTimestamp(now) + .updateTimestamp(now) + .stableId(catalogRoleStableId) + .parentStableId(catalogObj.stableId()) + .build(); + + state.writeOrReplace("role", roleObj); + + byName.put(nameKey, objRef(roleObj)); + byId.put(idKey, nameKey); + + return new ChangeResult.CommitChange<>(roleObj); + })); + } + + @Override + public void writeEntity( + @Nonnull PolarisCallContext callCtx, + @Nonnull PolarisBaseEntity entity, + // nameOrParentChanged is `true` if originalEntity==null or the parentId or the name changed + boolean nameOrParentChanged, + @Nullable PolarisBaseEntity originalEntity) { + throw useMetaStoreManager("create/update/rename/delete"); + } + + @Override + public void writeEntities( + @Nonnull PolarisCallContext callCtx, + @Nonnull List entities, + @Nullable List originalEntities) { + throw useMetaStoreManager("create/update/rename/delete"); + } + + private String logEntityInfo(PolarisEntityCore e) { + return format("%s #%d catalog:%d '%s'", e.getType(), e.getId(), e.getCatalogId(), e.getName()); + } + + private String logEntitiesInfo(List entities) { + return entities.stream() + .map(this::logEntityInfo) + .collect(Collectors.joining(", ", "(" + entities.size() + ") ", "")); + } + + EntityResult createEntity(PolarisBaseEntity entity) { + LOGGER.atDebug().addArgument(() -> logEntityInfo(entity)).log("create entity: {}"); + + return createOrUpdateEntity(EntityUpdate.Operation.CREATE, entity); + } + + EntitiesResult createEntities(List entities) { + LOGGER.atDebug().addArgument(() -> logEntitiesInfo(entities)).log("create entities: {}"); + + return createOrUpdateEntities(entities.stream(), EntityUpdate.Operation.CREATE); + } + + EntityResult updateEntity(PolarisBaseEntity entity) { + LOGGER.atDebug().addArgument(() -> logEntityInfo(entity)).log("update entity: {}"); + return createOrUpdateEntity(EntityUpdate.Operation.UPDATE, entity); + } + + EntitiesResult updateEntities(List entities) { + LOGGER + .atDebug() + .addArgument( + () -> logEntitiesInfo(entities.stream().map(EntityWithPath::getEntity).toList())) + .log("update entities: {}"); + + return createOrUpdateEntities( + entities.stream().map(EntityWithPath::getEntity), EntityUpdate.Operation.UPDATE); + } + + private EntityResult createOrUpdateEntity(EntityUpdate.Operation op, PolarisBaseEntity entity) { + var mutationResults = + performEntityMutations(Concern.forEntity(entity), List.of(new EntityUpdate(op, entity))); + return (EntityResult) mutationResults.results().getFirst(); + } + + private EntitiesResult createOrUpdateEntities( + Stream entitiesStream, EntityUpdate.Operation op) { + var byConcern = + entitiesStream + .map(e -> new EntityUpdate(op, e)) + .collect(Collectors.groupingBy(u -> Concern.forEntity(u.entity()))); + + if (byConcern.size() > 1) { + // TODO remove this check?? + throw new UnsupportedOperationException( + "Cannot atomically create entities against multiple targets: " + byConcern.keySet()); + } + + for (var concernChanges : byConcern.entrySet()) { + var results = performEntityMutations(concernChanges.getKey(), concernChanges.getValue()); + var firstFailure = results.firstFailure(); + if (firstFailure.isPresent()) { + var failure = firstFailure.get(); + return new EntitiesResult(failure.getReturnStatus(), failure.getExtraInformation()); + } + + return new EntitiesResult( + Page.fromItems( + results.results().stream() + .map(EntityResult.class::cast) + .map(EntityResult::getEntity) + .collect(Collectors.toList()))); + } + + return new EntitiesResult(Page.fromItems(List.of())); + } + + DropEntityResult dropEntity( + PolarisBaseEntity entityToDrop, Map cleanupProperties, boolean cleanup) { + requireNonNull(entityToDrop); + + LOGGER.atDebug().addArgument(() -> logEntityInfo(entityToDrop)).log("drop entity: {}"); + + var results = + performEntityMutations( + Concern.forEntity(entityToDrop), + List.of(new EntityUpdate(EntityUpdate.Operation.DELETE, entityToDrop, cleanup))); + + if (cleanup && PolarisEntityType.POLICY == entityToDrop.getType()) { + cleanup = false; + } + + var result = results.results().getFirst(); + if (result.isSuccess() && cleanup) { + // If cleanup, schedule a cleanup task for the entity. + // Do this here so that the drop operation and scheduling the cleanup task are + // transactional. + // Otherwise, we'll be unable to schedule the cleanup task + var dropped = results.droppedEntities().getFirst(); + + PolarisEntity.Builder taskEntityBuilder = + new PolarisEntity.Builder() + .setId(generateNewId()) + .setCatalogId(0L) + .setName("entityCleanup_" + entityToDrop.getId()) + .setType(PolarisEntityType.TASK) + .setSubType(PolarisEntitySubType.NULL_SUBTYPE) + .setCreateTimestamp(persistence.currentTimeMillis()); + + Map properties = new HashMap<>(); + properties.put( + PolarisTaskConstants.TASK_TYPE, + String.valueOf(AsyncTaskType.ENTITY_CLEANUP_SCHEDULER.typeCode())); + properties.put("data", PolarisObjectMapperUtil.serialize(dropped)); + taskEntityBuilder.setProperties(properties); + if (cleanupProperties != null) { + taskEntityBuilder.setInternalProperties(cleanupProperties); + } + var taskEntity = taskEntityBuilder.build(); + + try { + performEntityMutations( + new Concern(PolarisEntityType.TASK, 0L, false), + List.of(new EntityUpdate(EntityUpdate.Operation.CREATE, taskEntity))); + + if (entityToDrop.getType() == PolarisEntityType.POLICY) { + detachAllPolicyMappings(true, entityToDrop.getCatalogId(), entityToDrop.getId()); + } else if (PolicyMappingUtil.isValidTargetEntityType( + entityToDrop.getType(), entityToDrop.getSubType())) { + detachAllPolicyMappings(false, entityToDrop.getCatalogId(), entityToDrop.getId()); + } + + return new DropEntityResult(taskEntity.getId()); + } catch (Exception e) { + LOGGER.warn("Failed to write cleanup task entity for dropped entity", e); + } + } + + return (DropEntityResult) result; + } + + private MutationResults performEntityMutations(Concern concern, List updates) { + LOGGER + .atDebug() + .addArgument(updates.size()) + .addArgument(concern.entityType()) + .addArgument(concern.catalogId()) + .addArgument(concern.catalogContent() ? "catalog-content" : "non-catalog-content") + .addArgument( + () -> + updates.stream() + .map( + u -> + format( + "%s: %s #%s '%s'", + u.operation(), + u.entity().getType(), + u.entity().getId(), + u.entity().getName())) + .collect(Collectors.joining("\n ", "\n ", ""))) + .log("Applying {} updates to {} entities in catalog id {} as {} : {}"); + + if (concern.entityType() == PolarisEntityType.ROOT) { + checkArgument(updates.size() == 1, "Cannot write multiple root entities"); + try { + var update = updates.getFirst(); + checkArgument( + update.operation() == EntityUpdate.Operation.CREATE, + "Cannot update or delete the root entity"); + return persistence + .createCommitter(RootObj.ROOT_REF_NAME, RootObj.class, MutationResults.class) + .synchronizingLocally() + .commitRuntimeException( + (state, refObjSupplier) -> mutationAttemptForRoot(state, refObjSupplier, update)) + .orElseThrow(); + } finally { + memoizedIndexedAccess.invalidateIndexedAccess(0L, PolarisEntityType.ROOT.getCode()); + } + } + + var mutationResults = (MutationResults) null; + + if (concern.catalogContent()) { + try { + var committer = + persistence + .createCommitter( + perCatalogReferenceName( + CatalogStateObj.CATALOG_STATE_REF_NAME_PATTERN, concern.catalogId()), + CatalogStateObj.class, + MutationResults.class) + .synchronizingLocally(); + var commitRetryable = + new CatalogChangeCommitterWrapper( + ((state, ref, byName, byId, changes, locations) -> + mutationAttempt(concern, updates, state, byName, byId, changes, locations))); + mutationResults = committer.commitRuntimeException(commitRetryable).orElseThrow(); + } finally { + memoizedIndexedAccess.invalidateCatalogContent(concern.catalogId()); + } + } else { + mutationResults = + performChange( + concern.entityType(), + containerTypeForEntityType(concern.entityType(), false), + MutationResults.class, + concern.catalogId(), + ((state, ref, byName, byId) -> + mutationAttempt(concern, updates, state, byName, byId, null, null))); + } + + // TODO populate MutationResults.aclsToRemove and handle those, also need a maintenance + // operation to garbage-collect ACL entries for no longer existing entities. + + // TODO handle MutationResults.policyIndexKeysToRemove(), also need a maintenance + // operation to garbage-collect stale policy entries. + + return mutationResults; + } + + private ChangeResult mutationAttempt( + Concern concern, + List updates, + CommitterState state, + UpdatableIndex byName, + UpdatableIndex byId, + UpdatableIndex changes, + UpdatableIndex locations) { + var mutationResults = newMutableMutationResults(); + for (var update : updates) { + var entity = update.entity(); + var entityType = entity.getType(); + var persistence = state.persistence(); + var now = persistence.currentInstant(); + LOGGER.debug("Processing update {}", update); + + var entityParentId = entity.getParentId(); + + var entityIdKey = IndexKey.key(entity.getId()); + var originalNameKey = byId.get(entityIdKey); + + switch (update.operation()) { + case CREATE -> { + if (entityType == PolarisEntityType.PRINCIPAL) { + throw new IllegalArgumentException( + "Use createPrincipal function instead of writeEntity"); + } + + var entityObjBuilder = + mapToObj(entity, Optional.empty()) + .id(persistence.generateId()) + .createTimestamp(now) + .updateTimestamp(now) + .entityVersion(1); + + var nameKey = nameKeyForEntity(entity, byId, mutationResults::entityResult); + if (nameKey == null) { + break; + } + + var entityObj = entityObjBuilder.build(); + var existingRef = byName.get(nameKey); + if (existingRef != null || originalNameKey != null) { + // PolarisMetaStoreManager.createEntityIfNotExists: if the entity already exists, + // return it. + if (existingRef == null) { + existingRef = byName.get(originalNameKey); + } + if (existingRef != null) { + var originalObj = + (ObjBase) + state + .persistence() + .fetch( + existingRef, + objTypeForPolarisType(entityType, entity.getSubType()).targetClass()); + if (originalObj != null) { + var unchangedCompareObj = + objForChangeComparison(entity, Optional.empty(), originalObj); + if (unchangedCompareObj.equals(originalObj)) { + mutationResults.entityResultNoChange(entity); + break; + } + } + } + + mutationResults.entityResult( + BaseResult.ReturnStatus.ENTITY_ALREADY_EXISTS, + entitySubTypeCodeFromObjType(existingRef)); + break; + } + + updateLocationsIndex(locations, null, entityObj); + + mutationResults.entityResult(mapToEntity(entityObj, concern.catalogId())); + state.writeOrReplace("entity-" + entityObj.stableId(), entityObj); + + byName.put(nameKey, objRef(entityObj)); + byId.put(entityIdKey, nameKey); + + if (changes != null) { + checkState( + changes.put(nameKey, ChangeAdd.builder().build()), + "Entity '%s' updated more than once", + nameKey); + } + + LOGGER.debug( + "Added {} '{}' with ID {}...", + entityObj.type().name(), + nameKey, + entityObj.stableId()); + } + case UPDATE -> { + if (originalNameKey == null) { + mutationResults.entityResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND); + break; + } + var originalRef = byName.get(originalNameKey); + if (originalRef == null) { + mutationResults.entityResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND); + break; + } + var originalObj = + (ObjBase) + state + .persistence() + .fetch( + originalRef, + objTypeForPolarisType(entityType, entity.getSubType()).targetClass()); + if (originalObj == null) { + mutationResults.entityResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND); + break; + } + if (entity.getEntityVersion() != originalObj.entityVersion()) { + mutationResults.entityResult( + BaseResult.ReturnStatus.TARGET_ENTITY_CONCURRENTLY_MODIFIED); + break; + } + + var currentSecrets = maybeObjToPolarisPrincipalSecrets(originalObj); + + var renameOrMove = + entityParentId != originalObj.parentStableId() + || !entity.getName().equals(originalObj.name()); + + if (renameOrMove) { + if (entity.cannotBeDroppedOrRenamed()) { + mutationResults.entityResult(BaseResult.ReturnStatus.ENTITY_CANNOT_BE_RENAMED); + break; + } + if (!byName.remove(originalNameKey)) { + mutationResults.entityResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND); + break; + } + + var newNameKey = nameKeyForEntity(entity, byId, mutationResults::entityResult); + if (newNameKey == null) { + break; + } + + var existingRef = byName.get(newNameKey); + if (existingRef != null) { + mutationResults.entityResult( + BaseResult.ReturnStatus.ENTITY_ALREADY_EXISTS, + entitySubTypeCodeFromObjType(existingRef)); + break; + } + + var entityObj = + mapToObj(entity, currentSecrets) + .id(persistence.generateId()) + .updateTimestamp(now) + .entityVersion(originalObj.entityVersion() + 1) + .build(); + + updateLocationsIndex(locations, originalObj, entityObj); + + state.writeOrReplace("entity-" + entityObj.stableId(), entityObj); + + byName.put(newNameKey, objRef(entityObj)); + byId.put(entityIdKey, newNameKey); + if (changes != null) { + checkState( + changes.put( + newNameKey, ChangeRename.builder().renameFrom(originalNameKey).build()), + "Entity '%s' updated more than once", + newNameKey); + } + mutationResults.entityResult(mapToEntity(entityObj, concern.catalogId())); + + LOGGER.debug( + "Renamed {} '{}' with ID {} to '{}'...", + entityType, + originalNameKey, + entity.getId(), + newNameKey); + } else { + // no rename/move + + var unchangedCompareObj = objForChangeComparison(entity, currentSecrets, originalObj); + if (!unchangedCompareObj.equals(originalObj)) { + var entityObj = + mapToObj(entity, currentSecrets) + .id(persistence.generateId()) + .updateTimestamp(now) + .entityVersion(originalObj.entityVersion() + 1) + .build(); + + updateLocationsIndex(locations, originalObj, entityObj); + + state.writeOrReplace("entity-" + entityObj.stableId(), entityObj); + byName.put(originalNameKey, objRef(entityObj)); + if (changes != null) { + checkState( + changes.put(originalNameKey, ChangeUpdate.builder().build()), + "Entity '%s' updated more than once", + originalNameKey); + } + mutationResults.entityResult(mapToEntity(entityObj, concern.catalogId())); + + LOGGER.debug( + "Updated {} '{}' with ID {}...", entityType, originalNameKey, entity.getId()); + } else { + mutationResults.unchangedEntityResult(entity); + + LOGGER.debug( + "Not updating {} '{}' with ID {} (no change)...", + entityType, + originalNameKey, + entity.getId()); + } + } + } + case DELETE -> { + if (originalNameKey == null) { + mutationResults.dropResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND); + break; + } + var originalRef = byName.get(originalNameKey); + if (originalRef == null) { + mutationResults.dropResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND); + break; + } + var originalObj = + (ObjBase) + state + .persistence() + .fetch( + originalRef, + objTypeForPolarisType(entityType, entity.getSubType()).targetClass()); + if (originalObj == null) { + mutationResults.dropResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND); + break; + } + if (entity.getEntityVersion() != originalObj.entityVersion()) { + mutationResults.dropResult(BaseResult.ReturnStatus.TARGET_ENTITY_CONCURRENTLY_MODIFIED); + break; + } + if (entity.cannotBeDroppedOrRenamed()) { + mutationResults.dropResult(BaseResult.ReturnStatus.ENTITY_UNDROPPABLE); + break; + } + + updateLocationsIndex(locations, originalObj, null); + + var ok = + switch (entityType) { + case NAMESPACE -> { + if (hasChildren(concern.catalogId(), byName, byId, entity.getId())) { + mutationResults.dropResult(BaseResult.ReturnStatus.NAMESPACE_NOT_EMPTY); + yield false; + } + yield true; + } + case CATALOG -> { + var catalogState = memoizedIndexedAccess.catalogContent(entity.getId()); + + if (catalogState.nameIndex().map(idx -> idx.iterator().hasNext()).orElse(false)) { + mutationResults.dropResult(BaseResult.ReturnStatus.NAMESPACE_NOT_EMPTY); + yield false; + } + + // VALIDATION LOGIC COPIED + + var catalogRolesAccess = + memoizedIndexedAccess.indexedAccess( + entity.getId(), PolarisEntityType.CATALOG_ROLE.getCode()); + var numCatalogRoles = + catalogRolesAccess + .nameIndex() + .map( + idx -> { + var iter = idx.iterator(); + var cnt = 0; + if (iter.hasNext()) { + iter.next(); + cnt++; + } + if (iter.hasNext()) { + iter.next(); + cnt++; + } + return cnt; + }) + .orElse(0); + + // If we have 2, we cannot drop the catalog. If only one left, better be the admin + // role + if (numCatalogRoles > 1) { + mutationResults.dropResult(BaseResult.ReturnStatus.CATALOG_NOT_EMPTY); + yield false; + } + // If 1, drop the last catalog role. Should be the catalog admin role but don't + // validate this + // (note: no need to drop the catalog role here, it'll be eventually done by + // persistence-maintenance!) + + yield true; + } + case POLICY -> + memoizedIndexedAccess + .referenceHead(POLICY_MAPPINGS_REF_NAME, PolicyMappingsObj.class) + .map( + policyMappingsObj -> { + var index = + policyMappingsObj + .policyMappings() + .indexForRead(persistence, POLICY_MAPPING_SERIALIZER); + + var prefixKey = policyIndexPrefixKey((PolicyObj) originalObj, entity); + + var iter = index.iterator(prefixKey, prefixKey, false); + + if (iter.hasNext() && !update.cleanup()) { + mutationResults.dropResult( + BaseResult.ReturnStatus.POLICY_HAS_MAPPINGS); + return false; + } + + while (iter.hasNext()) { + var elem = iter.next(); + var key = + PolicyMappingsObj.PolicyMappingKey.fromIndexKey(elem.getKey()); + var reversed = key.reverse(); + + mutationResults.addPolicyIndexKeyToRemove(elem.getKey()); + mutationResults.addPolicyIndexKeyToRemove(reversed.toIndexKey()); + } + + return true; + }) + .orElse(true); + default -> true; + }; + if (ok) { + byId.remove(entityIdKey); + byName.remove(requireNonNull(originalNameKey)); + mutationResults.dropResult(entity); + + if (changes != null) { + changes.put(originalNameKey, ChangeRemove.builder().build()); + } + } + } + default -> throw new IllegalStateException("Unexpected operation " + update.operation()); + } + } + + var doCommit = mutationResults.anyChange && !mutationResults.hardFailure; + LOGGER.debug( + "{} changes (has changes: {}, failures: {})", + doCommit ? "Committing" : "Not committing", + mutationResults.anyChange, + mutationResults.failuresAsString()); + + return doCommit + ? new ChangeResult.CommitChange<>(mutationResults) + : new ChangeResult.NoChange<>(mutationResults); + } + + private static IndexKey policyIndexPrefixKey(PolicyObj policyObj, PolarisBaseEntity entity) { + // (Partial) index-key for the lookup + var keyByPolicyTemplate = + new PolicyMappingsObj.KeyByPolicy( + entity.getCatalogId(), entity.getId(), policyObj.policyType().getCode(), 0L, 0L); + + // Construct the prefix-key + return keyByPolicyTemplate.toPolicyWithTypePartialIndexKey(); + } + + private void updateLocationsIndex( + UpdatableIndex locations, ObjBase originalObj, ObjBase entityObj) { + var previousBaseLocation = + originalObj != null ? originalObj.properties().get(ENTITY_BASE_LOCATION) : null; + var entityBaseLocation = + entityObj != null ? entityObj.properties().get(ENTITY_BASE_LOCATION) : null; + + if (Objects.equals(previousBaseLocation, entityBaseLocation)) { + return; + } + + if (previousBaseLocation != null) { + var locationIdentifier = + Identifier.identifierFromLocationString( + StorageLocation.of(previousBaseLocation).withoutScheme()); + var locationKey = locationIdentifier.toIndexKey(); + var currentEntityIds = locations.get(locationKey); + var newIds = new HashSet(); + if (currentEntityIds != null) { + newIds.addAll(currentEntityIds.entityIds()); + } + newIds.remove(originalObj.stableId()); + if (newIds.isEmpty()) { + locations.remove(locationKey); + } else { + locations.put(locationKey, longValues(newIds)); + } + } + if (entityBaseLocation != null) { + var locationWithoutScheme = StorageLocation.of(entityBaseLocation).withoutScheme(); + var locationIdentifier = Identifier.identifierFromLocationString(locationWithoutScheme); + var locationKey = locationIdentifier.toIndexKey(); + var currentEntityIds = locations.get(locationKey); + var newIds = new HashSet(); + if (currentEntityIds != null) { + newIds.addAll(currentEntityIds.entityIds()); + } + newIds.add(entityObj.stableId()); + locations.put(locationKey, longValues(newIds)); + } + } + + Optional hasOverlappingSiblings( + T entity) { + var baseLocation = entity.getBaseLocation(); + if (baseLocation == null) { + return Optional.empty(); + } + + var checkLocation = StorageLocation.of(baseLocation).withoutScheme(); + + return memoizedIndexedAccess + .catalogContent(entity.getCatalogId()) + .refObj() + .flatMap( + catalogStateObj -> { + var locationsIndex = + catalogStateObj + .locations() + .map(i -> i.indexForRead(persistence, LONG_VALUES_SERIALIZER)) + .orElseGet(Index::empty); + var byId = + catalogStateObj.stableIdToName().indexForRead(persistence, INDEX_KEY_SERIALIZER); + var byName = + catalogStateObj.nameToObjRef().indexForRead(persistence, OBJ_REF_SERIALIZER); + + var locationIdentifier = identifierFromLocationString(checkLocation); + var locationIndexKey = locationIdentifier.toIndexKey(); + // TODO VALIDATE THE CHECKS HERE ! + var iter = locationsIndex.iterator(locationIndexKey, null, false); + if (!iter.hasNext()) { + return Optional.empty(); + } + + var elem = iter.next(); + var elemKey = elem.getKey(); + var elemIdentifier = indexKeyToIdentifier(elemKey); + if (!elemIdentifier.startsWith(locationIdentifier)) { + return Optional.empty(); + } + + return elem.getValue().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 -> { + // Check if conflict is the parent namespace - TODO recurse?? + var conflictingBaseLocation = + contentObj.properties().get(ENTITY_BASE_LOCATION); + return conflictingBaseLocation != null + ? conflictingBaseLocation + : String.join("/", elemIdentifier.elements()); + }) + .findFirst(); + }); + } + + private static IndexKey nameKeyForEntity( + PolarisEntityCore entity, + UpdatableIndex byId, + Consumer errorHandler) { + var identifierBuilder = Identifier.builder(); + var entityParentId = entity.getParentId(); + if (entityParentId != 0L && entityParentId != entity.getCatalogId()) { + var parentNameKey = byId.get(IndexKey.key(entityParentId)); + if (parentNameKey == null) { + errorHandler.accept(BaseResult.ReturnStatus.ENTITY_NOT_FOUND); + return null; + } + indexKeyToIdentifierBuilder(parentNameKey, identifierBuilder); + } + identifierBuilder.addElements(entity.getName()); + var identifier = identifierBuilder.build(); + return identifier.toIndexKey(); + } + + private static ObjBase objForChangeComparison( + PolarisBaseEntity entity, + Optional currentSecrets, + ObjBase originalObj) { + return mapToObj(entity, currentSecrets) + .updateTimestamp(originalObj.createTimestamp()) + .id(originalObj.id()) + .numParts(originalObj.numParts()) + .entityVersion(originalObj.entityVersion()) + .createTimestamp(originalObj.createTimestamp()) + .build(); + } + + private static Optional mutationAttemptForRoot( + CommitterState state, + Supplier> refObjSupplier, + EntityUpdate update) { + var entity = update.entity(); + var ref = TypeMapping.mapToObj(entity, Optional.empty()); + var refObj = refObjSupplier.get(); + return switch (update.operation()) { + case CREATE -> { + if (refObj.isPresent()) { + yield state.noCommit(singleEntityResult(BaseResult.ReturnStatus.ENTITY_ALREADY_EXISTS)); + } + yield state.commitResult(singleEntityResult(entity), ref, refObj); + } + case UPDATE -> { + if (refObj.isPresent()) { + var rootObj = refObj.get(); + if (entity.getEntityVersion() != rootObj.entityVersion()) { + yield state.noCommit( + singleEntityResult(BaseResult.ReturnStatus.TARGET_ENTITY_CONCURRENTLY_MODIFIED)); + } + } + yield state.commitResult(singleEntityResult(entity), ref, refObj); + } + default -> throw new IllegalStateException("Unexpected operation " + update.operation()); + }; + } + + @Override + public void writeToGrantRecords( + @Nonnull PolarisCallContext callCtx, @Nonnull PolarisGrantRecord grantRec) { + throw unimplemented(); + } + + @Override + public void deleteEntity(@Nonnull PolarisCallContext callCtx, @Nonnull PolarisBaseEntity entity) { + throw unimplemented(); + } + + @Override + public void deleteFromGrantRecords( + @Nonnull PolarisCallContext callCtx, @Nonnull PolarisGrantRecord grantRec) { + throw unimplemented(); + } + + @Override + public void deleteAllEntityGrantRecords( + @Nonnull PolarisCallContext callCtx, + @Nonnull PolarisEntityCore entity, + @Nonnull List grantsOnGrantee, + @Nonnull List grantsOnSecurable) { + throw unimplemented(); + } + + @Nullable + @Override + public PolarisBaseEntity lookupEntity( + @Nonnull PolarisCallContext callCtx, long catalogId, long entityId, int entityTypeCode) { + if (entityTypeCode == PolarisEntityType.ROOT.getCode()) { + return (PolarisEntityConstants.getNullId() == catalogId + && entityId == PolarisEntityConstants.getRootEntityId()) + ? lookupRoot().orElseThrow() + : null; + } + if (entityTypeCode == PolarisEntityType.NULL_TYPE.getCode()) { + return null; + } + if (entityTypeCode == PolarisEntityType.CATALOG.getCode()) { + catalogId = 0L; + } + + var access = memoizedIndexedAccess.indexedAccess(catalogId, entityTypeCode); + var resolved = access.byId(entityId); + + LOGGER.debug( + "lookupEntity result: entityTypeCode: {}, catalogId: {}, entityId: {} : {}", + entityTypeCode, + catalogId, + entityId, + resolved); + + return resolved + .flatMap(objBase -> filterIsEntityType(objBase, entityTypeCode)) + .map(objBase -> mapToEntity(objBase, access.catalogStableId())) + .orElse(null); + } + + @Nullable + @Override + public PolarisBaseEntity lookupEntityByName( + @Nonnull PolarisCallContext callCtx, + long catalogId, + long parentId, + int entityTypeCode, + @Nonnull String name) { + return lookupEntityByName(catalogId, parentId, entityTypeCode, name); + } + + PolarisBaseEntity lookupEntityByName( + long catalogId, long parentId, int entityTypeCode, String name) { + if (entityTypeCode == PolarisEntityType.ROOT.getCode()) { + return (PolarisEntityConstants.getNullId() == catalogId + && parentId == PolarisEntityConstants.getRootEntityId() + && PolarisEntityConstants.getRootContainerName().equals(name)) + ? lookupRoot().orElseThrow() + : null; + } + if (entityTypeCode == PolarisEntityType.NULL_TYPE.getCode()) { + return null; + } + if (entityTypeCode == PolarisEntityType.CATALOG.getCode()) { + catalogId = 0L; + } + + var rootAccess = parentId == catalogId; + var access = memoizedIndexedAccess.indexedAccess(catalogId, entityTypeCode); + var resolved = + rootAccess ? access.byNameOnRoot(name) : access.byParentIdAndName(parentId, name); + + LOGGER.debug( + "lookupEntityByName result : entityTypeCode: {}, catalogId: {}, parentId: {}, name: {} : {}", + entityTypeCode, + catalogId, + parentId, + name, + resolved); + + return resolved + .flatMap(objBase -> filterIsEntityType(objBase, entityTypeCode)) + .map(objBase -> mapToEntity(objBase, access.catalogStableId())) + .orElse(null); + } + + Optional lookupRoot() { + return memoizedIndexedAccess + .indexedAccess(0L, PolarisEntityType.ROOT.getCode()) + .byId(0L) + .map(root -> TypeMapping.mapToEntity(root, 0L)); + } + + @Override + public EntityNameLookupRecord lookupEntityIdAndSubTypeByName( + @Nonnull PolarisCallContext callCtx, + long catalogId, + long parentId, + int typeCode, + @Nonnull String name) { + if (typeCode == PolarisEntityType.NULL_TYPE.getCode()) { + return null; + } + if (typeCode == PolarisEntityType.CATALOG.getCode()) { + catalogId = 0L; + } + var ent = lookupEntityByName(catalogId, parentId, typeCode, name); + return ent != null ? new EntityNameLookupRecord(ent) : null; + } + + @Nonnull + @Override + public List lookupEntities( + @Nonnull PolarisCallContext callCtx, List entityIds) { + throw unimplemented(); + } + + @Nonnull + @Override + public List lookupEntityVersions( + @Nonnull PolarisCallContext callCtx, List entityIds) { + throw unimplemented(); + } + + @Override + public boolean hasChildren( + @Nonnull PolarisCallContext callCtx, + @Nullable PolarisEntityType optionalEntityType, + long catalogId, + long parentId) { + checkArgument(catalogId != 0L, "Must be called on a catalog"); + var access = memoizedIndexedAccess.catalogContent(catalogId); + + var nameIndex = access.nameIndex().orElse(null); + var idIndex = access.stableIdIndex().orElse(null); + return hasChildren(catalogId, nameIndex, idIndex, parentId); + } + + private boolean hasChildren( + long catalogId, Index nameIndex, Index stableIdIndex, long parentId) { + if (nameIndex != null) { + if (parentId == 0L || parentId == catalogId) { + return nameIndex.iterator().hasNext(); + } else { + var parentNameKey = + stableIdIndex != null ? stableIdIndex.get(IndexKey.key(parentId)) : null; + if (parentNameKey != null) { + var iter = nameIndex.iterator(parentNameKey, null, false); + // skip the parent itself + iter.next(); + if (iter.hasNext()) { + var e = iter.next(); + var nextKey = e.getKey(); + var parentIdent = indexKeyToIdentifier(parentNameKey); + var nextIdent = indexKeyToIdentifier(nextKey); + return nextIdent.parent().equals(parentIdent); + } + } + } + } + return false; + } + + Page fetchEntitiesAsPage( + long catalogStableId, + long parentId, + PolarisEntityType entityType, + PolarisEntitySubType entitySubType, + PageToken pageToken, + Function mapper, + Predicate filter, + Function transformer) { + + LOGGER.debug( + "fetchEntitiesAsPage, catalogId: {}, parentId: {}, entityType: {}, pageToken: {}", + catalogStableId, + parentId, + entityType, + pageToken); + + if (entityType == PolarisEntityType.NULL_TYPE) { + return Page.fromItems(List.of()); + } + if (entityType == PolarisEntityType.CATALOG) { + catalogStableId = 0L; + } + + var paginationToken = pageToken.valueAs(PersistencePaginationToken.class); + var pageTokenOffset = paginationToken.map(PersistencePaginationToken::key); + + var catalogContent = isCatalogContent(entityType); + var access = + paginationToken.isPresent() + ? memoizedIndexedAccess.indexedAccessDirect( + paginationToken.orElseThrow().containerObjRef()) + : catalogContent + ? memoizedIndexedAccess.catalogContent(catalogStableId) + : memoizedIndexedAccess.indexedAccess(catalogStableId, entityType.getCode()); + var nameIndex = access.nameIndex().orElse(null); + + if (nameIndex != null) { + var objRefs = Stream.>empty(); + if (catalogStableId != 0L) { + if (parentId == 0L || parentId == catalogStableId) { + // list on catalog root + objRefs = + Streams.stream(nameIndex.iterator(pageTokenOffset.orElse(null), null, false)) + .filter(Objects::nonNull) + .filter( + e -> { + var ident = indexKeyToIdentifier(e.getKey()); + return ident.elements().size() == 1; + }); + } else { + // list on namespace + var prefixKeyOptional = access.nameKeyById(parentId); + if (prefixKeyOptional.isPresent()) { + var prefixKey = prefixKeyOptional.get(); + var offsetKey = + pageTokenOffset.filter(pto -> pto.compareTo(prefixKey) >= 0).orElse(prefixKey); + var prefix = indexKeyToIdentifier(prefixKey); + var prefixElems = prefix.elements(); + var directChildLevel = prefixElems.size() + 1; + objRefs = + Streams.stream(nameIndex.iterator(offsetKey, null, false)) + .takeWhile( + e -> { + var ident = indexKeyToIdentifier(requireNonNull(e).getKey()); + var identElems = ident.elements(); + if (identElems.size() < prefixElems.size() + 1) { + return ident.equals(prefix); + } + return identElems.subList(0, prefixElems.size()).equals(prefixElems); + }) + .filter( + e -> { + var ident = indexKeyToIdentifier(requireNonNull(e).getKey()); + return ident.elements().size() == directChildLevel; + }); + } + } + } else { + objRefs = Streams.stream(nameIndex.iterator(pageTokenOffset.orElse(null), null, false)); + } + + if (LOGGER.isDebugEnabled()) { + objRefs = + objRefs.peek( + o -> + LOGGER.debug( + " listEntitiesStream (before type filter): {} : {}", + o.getKey(), + o.getValue())); + } + + var filterType = objTypeForPolarisTypeForFiltering(entityType, entitySubType); + objRefs = + objRefs.filter( + o -> + filterType.isAssignableFrom( + ObjTypes.objTypeById(o.getValue().type()).targetClass())); + + return listEntitiesBuildPage(access, pageToken, mapper, filter, transformer, objRefs); + } + + return Page.fromItems(List.of()); + } + + /** + * Number of {@link ObjBase objects} to {@link Persistence#fetchMany(Class, ObjRef...) + * bulk-fetch}. + */ + private static final int FETCH_PAGE_SIZE = 25; + + private Page listEntitiesBuildPage( + IndexedContainerAccess access, + PageToken pageToken, + Function mapper, + Predicate filter, + Function transformer, + Stream> objRefs) { + var limit = pageToken.pageSize().orElse(Integer.MAX_VALUE); + var nextToken = (PersistencePaginationToken) null; + var result = new ArrayList(); + + var fetchBuffer = new ArrayList>(); + + for (var objRefIter = objRefs.iterator(); objRefIter.hasNext(); ) { + var keyAndRef = objRefIter.next(); + fetchBuffer.add(keyAndRef); + if (fetchBuffer.size() == FETCH_PAGE_SIZE) { + nextToken = + listEntitiesBuildPagePart( + access, fetchBuffer, mapper, filter, transformer, result, limit); + fetchBuffer.clear(); + if (nextToken != null || result.size() == limit) { + break; + } + } + } + if (!fetchBuffer.isEmpty()) { + nextToken = + listEntitiesBuildPagePart( + access, fetchBuffer, mapper, filter, transformer, result, limit); + } + + return Page.page(pageToken, result, nextToken); + } + + @Nullable + private PersistencePaginationToken listEntitiesBuildPagePart( + IndexedContainerAccess access, + List> fetchBuffer, + Function mapper, + Predicate filter, + Function transformer, + List result, + int limit) { + var objs = + persistence.fetchMany( + ObjBase.class, fetchBuffer.stream().map(Map.Entry::getValue).toArray(ObjRef[]::new)); + for (int i = 0; i < fetchBuffer.size(); i++) { + var obj = objs[i]; + + if (obj == null) { + continue; + } + var intermediate = mapper.apply(obj); + if (intermediate == null || !filter.test(intermediate)) { + continue; + } + var transformed = transformer.apply(intermediate); + if (transformed == null) { + continue; + } + + if (result.size() == limit) { + return PersistencePaginationToken.paginationToken( + ObjRef.objRef(access.refObj().orElseThrow()), fetchBuffer.get(i).getKey()); + } + + result.add(transformed); + } + return null; + } + + @Nonnull + @Override + public Page listFullEntities( + @Nonnull PolarisCallContext callCtx, + long catalogId, + long parentId, + @Nonnull PolarisEntityType entityType, + @Nonnull PolarisEntitySubType entitySubType, + @Nonnull Predicate entityFilter, + @Nonnull Function transformer, + PageToken pageToken) { + return fetchEntitiesAsPage( + catalogId, + parentId, + entityType, + PolarisEntitySubType.ANY_SUBTYPE, + pageToken, + objBase -> mapToEntity(objBase, catalogId), + entityFilter, + transformer); + } + + @Nonnull + @Override + public Page listEntities( + @Nonnull PolarisCallContext callCtx, + long catalogId, + long parentId, + @Nonnull PolarisEntityType entityType, + @Nonnull PolarisEntitySubType entitySubType, + @Nonnull PageToken pageToken) { + return listFullEntities( + callCtx, + catalogId, + parentId, + entityType, + entitySubType, + entity -> true, + EntityNameLookupRecord::new, + pageToken); + } + + @Override + public void writeToPolicyMappingRecords( + @Nonnull PolarisCallContext callCtx, @Nonnull PolarisPolicyMappingRecord record) { + attachDetachPolicyOnEntity( + record.getPolicyCatalogId(), + record.getPolicyId(), + requireNonNull(PolicyType.fromCode(record.getPolicyTypeCode())), + record.getTargetCatalogId(), + record.getTargetId(), + true, + record.getParametersAsMap()); + } + + @Override + public void deleteFromPolicyMappingRecords( + @Nonnull PolarisCallContext callCtx, @Nonnull PolarisPolicyMappingRecord record) { + attachDetachPolicyOnEntity( + record.getPolicyCatalogId(), + record.getPolicyId(), + requireNonNull(PolicyType.fromCode(record.getPolicyTypeCode())), + record.getTargetCatalogId(), + record.getTargetId(), + false, + record.getParametersAsMap()); + } + + @Override + public void deleteAllEntityPolicyMappingRecords( + @Nonnull PolarisCallContext callCtx, + @Nonnull PolarisBaseEntity entity, + @Nonnull List mappingOnTarget, + @Nonnull List mappingOnPolicy) { + throw new UnsupportedOperationException("IMPLEMENT ME"); + } + + @Nullable + @Override + public PolarisPolicyMappingRecord lookupPolicyMappingRecord( + @Nonnull PolarisCallContext callCtx, + long targetCatalogId, + long targetId, + int policyTypeCode, + long policyCatalogId, + long policyId) { + return memoizedIndexedAccess + .referenceHead(POLICY_MAPPINGS_REF_NAME, PolicyMappingsObj.class) + .map( + policyMappingsObj -> { + var index = + policyMappingsObj + .policyMappings() + .indexForRead(persistence, POLICY_MAPPING_SERIALIZER); + + // (Partial) index-key for the lookup + var keyByEntity = + new PolicyMappingsObj.KeyByEntity( + targetCatalogId, targetId, policyTypeCode, policyCatalogId, policyId); + + var elem = index.get(keyByEntity.toIndexKey()); + if (elem != null) { + return keyByEntity.toMappingRecord(elem); + } + + return null; + }) + .orElse(null); + } + + @Nonnull + @Override + public List loadPoliciesOnTargetByType( + @Nonnull PolarisCallContext callCtx, + long targetCatalogId, + long targetId, + int policyTypeCode) { + var r = + loadPoliciesOnEntity( + null, + targetCatalogId, + targetId, + Optional.of(requireNonNull(PolicyType.fromCode(policyTypeCode)))); + return r.getPolicyMappingRecords(); + } + + @Nonnull + @Override + public List loadAllPoliciesOnTarget( + @Nonnull PolarisCallContext callCtx, long targetCatalogId, long targetId) { + var r = loadPoliciesOnEntity(null, targetCatalogId, targetId, Optional.empty()); + return r.getPolicyMappingRecords(); + } + + @Nonnull + @Override + public List loadAllTargetsOnPolicy( + @Nonnull PolarisCallContext callCtx, + long policyCatalogId, + long policyId, + int policyTypeCode) { + var r = + loadEntitiesOnPolicy( + null, + policyCatalogId, + policyId, + Optional.of(requireNonNull(PolicyType.fromCode(policyTypeCode)))); + return r.getPolicyMappingRecords(); + } + + private void detachAllPolicyMappings(boolean policyNotEntity, long catalogId, long id) { + try { + var committer = + persistence + .createCommitter(POLICY_MAPPINGS_REF_NAME, PolicyMappingsObj.class, String.class) + .synchronizingLocally(); + var ignore = + committer + .commitRuntimeException( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + var index = + refObj + .map( + ref -> + ref.policyMappings() + .asUpdatableIndex( + state.persistence(), POLICY_MAPPING_SERIALIZER)) + .orElseGet( + () -> newUpdatableIndex(persistence, POLICY_MAPPING_SERIALIZER)); + var builder = PolicyMappingsObj.builder(); + refObj.ifPresent(builder::from); + + var keyBy = + policyNotEntity + ? new PolicyMappingsObj.KeyByPolicy(catalogId, id, 0, 0L, 0L) + .toPolicyPartialIndexKey() + : new PolicyMappingsObj.KeyByEntity(catalogId, id, 0, 0L, 0L) + .toEntityPartialIndexKey(); + + var keys = new ArrayList(); + for (var iter = index.iterator(keyBy, keyBy, true); iter.hasNext(); ) { + var elem = iter.next(); + keys.add(elem.getKey()); + } + + if (keys.isEmpty()) { + return state.noCommit(""); + } + + for (var key : keys) { + index.remove(key); + index.remove( + PolicyMappingsObj.PolicyMappingKey.fromIndexKey(key) + .reverse() + .toIndexKey()); + } + + builder.policyMappings(index.toIndexed("mappings", state::writeOrReplace)); + return state.commitResult("", builder, refObj); + }) + .orElseThrow(); + } finally { + memoizedIndexedAccess.invalidateReferenceHead(POLICY_MAPPINGS_REF_NAME); + } + } + + PolicyAttachmentResult attachDetachPolicyOnEntity( + long policyCatalogId, + long policyId, + @Nonnull PolicyType policyType, + long targetCatalogId, + long targetId, + boolean doAttach, + @Nonnull Map parameters) { + + // TODO Alternative approach 1: + // - separate reference, similar to grants + // - key by entity: + // - E/entityCatalogId/entityId/policyType/policyCatalogId/policyId + // VALUE: properties + // - key by policy: + // - P/policyType/policyCatalogId/policyId/entityCatalogId/entityId/entityType + // VALUE: (empty) + // - remove PolicyAttachableContentObj + // --> code should become simpler + // --> add to maintenance + + try { + var committer = + persistence + .createCommitter( + POLICY_MAPPINGS_REF_NAME, PolicyMappingsObj.class, PolicyAttachmentResult.class) + .synchronizingLocally(); + return committer + .commitRuntimeException( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + var index = + refObj + .map( + ref -> + ref.policyMappings() + .asUpdatableIndex( + state.persistence(), POLICY_MAPPING_SERIALIZER)) + .orElseGet( + () -> + IndexContainer.newUpdatableIndex( + persistence, POLICY_MAPPING_SERIALIZER)); + var builder = PolicyMappingsObj.builder(); + refObj.ifPresent(builder::from); + + var policyCatalogAccess = memoizedIndexedAccess.catalogContent(policyCatalogId); + + var policyOptional = policyCatalogAccess.byId(policyId); + if (policyOptional.isEmpty()) { + return state.noCommit( + new PolicyAttachmentResult( + BaseResult.ReturnStatus.POLICY_MAPPING_NOT_FOUND, null)); + } + if (targetCatalogId != 0L && targetCatalogId != targetId) { + // catalog content, check whether the entity exists + var targetCatalogAccess = + targetCatalogId == policyCatalogId + ? policyCatalogAccess + : memoizedIndexedAccess.catalogContent(targetCatalogId); + var targetOptional = targetCatalogAccess.byId(targetId); + if (targetOptional.isEmpty()) { + return state.noCommit( + new PolicyAttachmentResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null)); + } + } + // else: against catalog, assume that it exists + + var result = + new PolicyAttachmentResult( + new PolarisPolicyMappingRecord( + targetCatalogId, + targetId, + policyCatalogId, + policyId, + policyType.getCode(), + parameters)); + + var keyByPolicy = + new PolicyMappingsObj.KeyByPolicy( + policyCatalogId, policyId, policyType.getCode(), targetCatalogId, targetId); + var keyByEntity = + new PolicyMappingsObj.KeyByEntity( + targetCatalogId, targetId, policyType.getCode(), policyCatalogId, policyId); + + var changed = false; + if (doAttach) { + if (policyType.isInheritable()) { + // The contract says that at max one policy of the same inheritable policy type + // must + // be attached to a single entity. + var policyPrefixKey = keyByEntity.toPolicyTypePartialIndexKey(); + var iter = index.iterator(policyPrefixKey, policyPrefixKey, false); + if (iter.hasNext()) { + var key = + PolicyMappingsObj.PolicyMappingKey.fromIndexKey(iter.next().getKey()); + if (!(key instanceof PolicyMappingsObj.KeyByEntity existing + && existing.policyCatalogId() == policyCatalogId + && existing.policyId() == policyId)) { + // same policy-type attached, error-out + return state.noCommit( + new PolicyAttachmentResult( + POLICY_MAPPING_OF_SAME_TYPE_ALREADY_EXISTS, null)); + } + } + } + + // note: parameters are only added to the "by entity" entry + index.put(keyByPolicy.toIndexKey(), PolicyMapping.EMPTY); + index.put( + keyByEntity.toIndexKey(), + PolicyMapping.builder().parameters(parameters).build()); + changed = true; + } else { + changed |= index.remove(keyByPolicy.toIndexKey()); + changed |= index.remove(keyByEntity.toIndexKey()); + } + + if (changed) { + builder.policyMappings(index.toIndexed("mappings", state::writeOrReplace)); + return state.commitResult(result, builder, refObj); + } + return state.noCommit(result); + }) + .orElseThrow(); + } finally { + memoizedIndexedAccess.invalidateReferenceHead(POLICY_MAPPINGS_REF_NAME); + } + } + + LoadPolicyMappingsResult loadPoliciesOnEntity( + @Nullable PolarisEntityType entityType, + long catalogId, + long id, + Optional policyType) { + if (entityType != null + && entityType != PolarisEntityType.CATALOG + && !isCatalogContent(entityType)) { + return new LoadPolicyMappingsResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null); + } + + return memoizedIndexedAccess + .referenceHead(POLICY_MAPPINGS_REF_NAME, PolicyMappingsObj.class) + .map( + policyMappingsObj -> { + var mappingRecords = new ArrayList(); + var policyEntities = new ArrayList(); + var seenPolicies = new HashSet(); + + var index = + policyMappingsObj + .policyMappings() + .indexForRead(persistence, POLICY_MAPPING_SERIALIZER); + + // (Partial) index-key for the lookup + var keyByEntityTemplate = + new PolicyMappingsObj.KeyByEntity( + catalogId, id, policyType.map(PolicyType::getCode).orElse(0), 0L, 0L); + + // Construct the prefix-key, depending on whether to look for all attached policies or + // attached policies having the given policy-type + var prefixKey = + policyType.isPresent() + ? keyByEntityTemplate.toPolicyTypePartialIndexKey() + : keyByEntityTemplate.toEntityPartialIndexKey(); + + for (var iter = index.iterator(prefixKey, prefixKey, false); iter.hasNext(); ) { + var elem = iter.next(); + var key = PolicyMappingsObj.PolicyMappingKey.fromIndexKey(elem.getKey()); + if (key instanceof PolicyMappingsObj.KeyByEntity byEntity) { + if (seenPolicies.add(byEntity.policyId())) { + memoizedIndexedAccess + .catalogContent(byEntity.policyCatalogId()) + .byId(byEntity.policyId()) + .flatMap( + objBase -> + TypeMapping.filterIsEntityType(objBase, PolarisEntityType.POLICY)) + .map(obj -> mapToEntity(obj, byEntity.policyCatalogId())) + .ifPresent(policyEntities::add); + } + mappingRecords.add(byEntity.toMappingRecord(elem.getValue())); + } else { + // `key` is not what we're looking for. + // This should actually never happen due to the prefix-key. + break; + } + } + + return new LoadPolicyMappingsResult(mappingRecords, policyEntities); + }) + .orElse(new LoadPolicyMappingsResult(List.of(), List.of())); + } + + @SuppressWarnings("SameParameterValue") + private LoadPolicyMappingsResult loadEntitiesOnPolicy( + @Nullable PolarisEntityType entityType, + long policyCatalogId, + long policyId, + Optional policyType) { + if (entityType != null && !isCatalogContent(entityType)) { + return new LoadPolicyMappingsResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null); + } + + return memoizedIndexedAccess + .referenceHead(POLICY_MAPPINGS_REF_NAME, PolicyMappingsObj.class) + .map( + policyMappingsObj -> { + var mappingRecords = new ArrayList(); + var policyEntities = new ArrayList(); + var seenPolicies = new HashSet(); + + var index = + policyMappingsObj + .policyMappings() + .indexForRead(persistence, POLICY_MAPPING_SERIALIZER); + + // (Partial) index-key for the lookup + var keyByPolicyTemplate = + new PolicyMappingsObj.KeyByPolicy( + policyCatalogId, + policyId, + policyType.map(PolicyType::getCode).orElse(0), + 0L, + 0L); + + // Construct the prefix-key, depending on whether to look for all attached policies or + // attached policies having the given policy-type + var prefixKey = + policyType.isPresent() + ? keyByPolicyTemplate.toPolicyWithTypePartialIndexKey() + : keyByPolicyTemplate.toPolicyPartialIndexKey(); + + for (var iter = index.iterator(prefixKey, prefixKey, false); iter.hasNext(); ) { + var elem = iter.next(); + var key = PolicyMappingsObj.PolicyMappingKey.fromIndexKey(elem.getKey()); + if (key instanceof PolicyMappingsObj.KeyByPolicy byPolicy) { + if (seenPolicies.add(byPolicy.policyId())) { + memoizedIndexedAccess + .catalogContent(byPolicy.policyCatalogId()) + .byId(byPolicy.policyId()) + .flatMap( + objBase -> + TypeMapping.filterIsEntityType(objBase, PolarisEntityType.POLICY)) + .map(obj -> mapToEntity(obj, byPolicy.policyCatalogId())) + .ifPresent(policyEntities::add); + } + mappingRecords.add(byPolicy.toMappingRecord(elem.getValue())); + } else { + // `key` is not what we're looking for. + // This should actually never happen due to the prefix-key + break; + } + } + + return new LoadPolicyMappingsResult(mappingRecords, policyEntities); + }) + .orElse(new LoadPolicyMappingsResult(List.of(), List.of())); + } + + // grants + + @Override + public int lookupEntityGrantRecordsVersion( + @Nonnull PolarisCallContext callCtx, long catalogId, long entityId) { + throw useMetaStoreManager("loadGrantsOnSecurable"); + } + + @Nullable + @Override + public PolarisGrantRecord lookupGrantRecord( + @Nonnull PolarisCallContext callCtx, + long securableCatalogId, + long securableId, + long granteeCatalogId, + long granteeId, + int privilegeCode) { + throw useMetaStoreManager("loadGrantsOnSecurable"); + } + + @Nonnull + @Override + public List loadAllGrantRecordsOnSecurable( + @Nonnull PolarisCallContext callCtx, long securableCatalogId, long securableId) { + throw useMetaStoreManager("loadGrantsOnSecurable"); + } + + @Nonnull + @Override + public List loadAllGrantRecordsOnGrantee( + @Nonnull PolarisCallContext callCtx, long granteeCatalogId, long granteeId) { + throw useMetaStoreManager("loadGrantsToGrantee"); + } + + @FunctionalInterface + interface AclEntryHandler { + void handle(SecurableAndGrantee securableAndGrantee, PrivilegeSet granted); + } + + List allGrantRecords(PolarisBaseEntity entity) { + var catalogId = entity.getCatalogId(); + var aclName = GrantTriplet.forEntity(entity).toRoleName(); + + LOGGER.debug("allGrantRecords for {}", aclName); + + var collector = new GrantRecordsCollector(catalogId); + + collectGrantRecords( + catalogId, + aclName, + (securableAndGrantee, granted) -> { + var indexedAccess = + memoizedIndexedAccess.indexedAccess( + securableAndGrantee.securableCatalogId(), + securableAndGrantee.securableTypeCode()); + var existing = indexedAccess.nameKeyById(securableAndGrantee.securableId()); + if (existing.isPresent()) { + collector.handle(securableAndGrantee, granted); + } + }); + + var grantRecords = collector.grantRecords; + LOGGER + .atTrace() + .addArgument(grantRecords.size()) + .addArgument( + () -> + grantRecords.stream() + .map(PolarisGrantRecord::toString) + .collect(Collectors.joining("\n ", "\n ", ""))) + .log("Returning {} grant records: {}"); + return grantRecords; + } + + LoadGrantsResult loadGrants(long catalogId, long id, int entityTypeCode, boolean onSecurable) { + LOGGER.debug( + "loadGrants on {} for catalog:{}, id:{}, entityType:{}({})", + onSecurable ? "securable" : "grantee", + catalogId, + id, + PolarisEntityType.fromCode(entityTypeCode), + entityTypeCode); + var aclName = new GrantTriplet(true, catalogId, id, entityTypeCode).toRoleName(); + + var collector = new GrantRecordsCollector(catalogId); + var entities = new ArrayList(); + var ids = new HashSet(); + + collectGrantRecords( + catalogId, + aclName, + ((securableAndGrantee, granted) -> { + var targetCatalogId = + onSecurable + ? securableAndGrantee.granteeCatalogId() + : securableAndGrantee.securableCatalogId(); + var targetId = + onSecurable ? securableAndGrantee.granteeId() : securableAndGrantee.securableId(); + var targetTypeCode = + onSecurable + ? securableAndGrantee.granteeTypeCode() + : securableAndGrantee.securableTypeCode(); + + var indexedAccess = memoizedIndexedAccess.indexedAccess(targetCatalogId, targetTypeCode); + var entityOptional = + indexedAccess.byId(targetId).map(o -> mapToEntity(o, targetCatalogId)); + + if (entityOptional.isPresent()) { + PolarisBaseEntity entity = entityOptional.get(); + LOGGER.trace( + " Adding entity to load-grants-result: catalog:{}, id:{}, type:{}", + entity.getCatalogId(), + entity.getId(), + entity.getType()); + collector.handle(securableAndGrantee, granted); + if (ids.add(targetId)) { + entities.add(entity); + } + } else { + LOGGER.trace(" Not returning stale entity reference"); + } + })); + + LOGGER.trace( + "Returning {} grant records for loadGrants for catalog:{}, id:{}, entityType:{}({})", + collector.grantRecords.size(), + catalogId, + id, + PolarisEntityType.fromCode(entityTypeCode), + entityTypeCode); + + return new LoadGrantsResult(1, collector.grantRecords, entities); + } + + private void collectGrantRecords( + long catalogStableId, String aclName, AclEntryHandler aclEntryConsumer) { + var refName = grantsRefName(catalogStableId); + + LOGGER.debug("Checking ACL '{}' on '{}'", aclName, refName); + + var head = memoizedIndexedAccess.referenceHead(refName, GrantsObj.class); + if (head.isPresent()) { + var grantsObj = head.get(); + var securablesIndex = grantsObj.acls().indexForRead(persistence, OBJ_REF_SERIALIZER); + var securableKey = IndexKey.key(aclName); + + LOGGER.trace("Processing existing ACL {}", aclName); + Optional.ofNullable(securablesIndex.get(securableKey)) + .flatMap(aclObjRef -> Optional.ofNullable(persistence.fetch(aclObjRef, AclObj.class))) + .ifPresent( + aclObj -> { + var acl = aclObj.acl(); + + acl.forEach( + (role, entry) -> { + var triplet = GrantTriplet.fromRoleName(role); + LOGGER + .atTrace() + .setMessage(" ACL has securable {} ({}) with privileges {}") + .addArgument(role) + .addArgument(PolarisEntityType.fromCode(triplet.typeCode())) + .addArgument( + () -> + entry.granted().stream() + .map(Privilege::name) + .collect(Collectors.joining(", "))) + .log(); + + var securableAndGrantee = + SecurableAndGrantee.forTriplet(catalogStableId, aclObj, triplet); + aclEntryConsumer.handle(securableAndGrantee, entry.granted()); + }); + }); + } else { + LOGGER.trace("ACL {} does not exist", aclName); + } + } + + static class GrantRecordsCollector implements AclEntryHandler { + final List grantRecords = new ArrayList<>(); + final long catalogId; + + GrantRecordsCollector(long catalogId) { + this.catalogId = catalogId; + } + + @Override + public void handle(SecurableAndGrantee securableAndGrantee, PrivilegeSet granted) { + for (var privilege : granted) { + var privilegeCode = PolarisPrivilege.valueOf(privilege.name()).getCode(); + var record = securableAndGrantee.grantRecordForPrivilege(privilegeCode); + LOGGER.trace( + " Yielding grant record: securable: catalog:{} id:{} - grantee: catalog:{} id:{} - privilege: {}", + record.getSecurableCatalogId(), + record.getSecurableId(), + record.getGranteeCatalogId(), + record.getGranteeId(), + record.getPrivilegeCode()); + grantRecords.add(record); + } + } + } + + private static String grantsRefName(long catalogStableId) { + /* + TODO better move catalog-related ACLs to the catalog-grants (needs extensive testing!) + + return catalogStableId != 0L + ? perCatalogReferenceName(CATALOG_GRANTS_REF_NAME_PATTERN, catalogStableId) + : REALM_GRANTS_REF_NAME; + */ + return REALM_GRANTS_REF_NAME; + } + + boolean persistGrantsOrRevokes(long catalogStableId, boolean grant, Grant... grants) { + /* + TODO better move catalog-related ACLs to the catalog-grants (needs extensive testing!) + + if (catalogStableId != 0L) { + doPersistGrantsOrRevokes( + perCatalogReferenceName( + CatalogGrantsObj.CATALOG_GRANTS_REF_NAME_PATTERN, catalogStableId), + CatalogGrantsObj::builder, + grant, + grants); + return; + } + */ + + return doPersistGrantsOrRevokes(REALM_GRANTS_REF_NAME, RealmGrantsObj::builder, grant, grants); + } + + @SuppressWarnings("SameParameterValue") + private > boolean doPersistGrantsOrRevokes( + String refName, Supplier builder, boolean doGrant, Grant... grants) { + LOGGER.debug( + "Persisting {} on '{}' for '{}'", + doGrant ? "grants" : "revokes", + refName, + Arrays.asList(grants)); + + try { + return persistence + .createCommitter(refName, GrantsObj.class, String.class) + .synchronizingLocally() + .commitRuntimeException( + new CommitRetryable<>() { + @Nonnull + @Override + public Optional attempt( + @Nonnull CommitterState state, + @Nonnull Supplier> refObjSupplier) + throws CommitException { + var persistence = state.persistence(); + var refObj = refObjSupplier.get(); + + var ref = builder.get(); + refObj.ifPresent(ref::from); + + var securablesIndex = + refObj + .map(GrantsObj::acls) + .map(c -> c.asUpdatableIndex(persistence, OBJ_REF_SERIALIZER)) + .orElseGet(() -> newUpdatableIndex(persistence, OBJ_REF_SERIALIZER)); + + var changed = false; + for (var g : grants) { + var securable = GrantTriplet.forEntity(g.securable()); + var grantee = GrantTriplet.forEntity(g.grantee()); + var forSec = grantee.asDirected(); + var privilege = privileges.byName(g.privilege().name()); + changed |= + processGrant(state, securable, forSec, securablesIndex, privilege, doGrant); + changed |= + processGrant( + state, grantee, securable, securablesIndex, privilege, doGrant); + } + + if (!changed) { + return state.noCommit(); + } + + ref.acls(securablesIndex.toIndexed("idx-sec-", state::writeOrReplace)); + + return commitResult(state, ref, refObj); + } + + // Some fun with Java generics... + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Optional commitResult( + CommitterState state, + REF_BUILDER ref, + Optional refObj) { + var cs = (CommitterState) state; + var refBuilder = (BaseCommitObj.Builder) ref; + return cs.commitResult("", refBuilder, refObj); + } + + private boolean processGrant( + CommitterState state, + GrantTriplet aclTriplet, + GrantTriplet grantee, + UpdatableIndex securablesIndex, + Privilege privilege, + boolean doGrant) { + + var aclName = aclTriplet.toRoleName(); + var granteeRoleName = grantee.toRoleName(); + + LOGGER.trace( + "{} {} {} '{}' ({}) on '{}' in ACL '{}' ({})", + doGrant ? "Granting" : "Revoking", + privilege.name(), + doGrant ? "on" : "from", + granteeRoleName, + PolarisEntityType.fromCode(grantee.typeCode()), + refName, + aclName, + PolarisEntityType.fromCode(aclTriplet.typeCode())); + + var aclKey = IndexKey.key(aclName); + + var aclRef = securablesIndex.get(aclKey); + var aclObjOptional = + Optional.ofNullable(aclRef) + .map(r -> state.persistence().fetch(r, AclObj.class)); + var aclObjBuilder = + aclObjOptional + .map(AclObj.builder()::from) + .orElseGet(AclObj::builder) + .id(generateNewId()) + .securableId(aclTriplet.id()) + .securableTypeCode(aclTriplet.typeCode()); + + var aclBuilder = + aclObjOptional + .map(o -> privileges.newAclBuilder().from(o.acl())) + .orElseGet(privileges::newAclBuilder); + + aclBuilder.modify( + granteeRoleName, + aclEntryBuilder -> { + if (doGrant) { + aclEntryBuilder.grant(privilege); + } else { + aclEntryBuilder.revoke(privilege); + } + }); + + var acl = aclBuilder.build(); + if (aclObjOptional.map(obj -> obj.acl().equals(acl)).orElseGet(() -> !doGrant)) { + // aclObj not changed + return false; + } + aclObjBuilder.acl(acl); + + var aclObj = aclObjBuilder.build(); + + state.writeOrReplace("acl-" + aclTriplet.id(), aclObj); + + securablesIndex.put(aclKey, objRef(aclObj)); + + // aclObj changed + return true; + } + }) + .isPresent(); + } finally { + memoizedIndexedAccess.invalidateReferenceHead(refName); + } + } + + @Nullable + @Override + public + PolarisStorageIntegration loadPolarisStorageIntegration( + @Nonnull PolarisCallContext callCtx, @Nonnull PolarisBaseEntity entity) { + var storageConfig = BaseMetaStoreManager.extractStorageConfiguration(diagnostics, entity); + return storageIntegrationProvider.getStorageIntegrationForConfig(storageConfig); + } + + @Override + public void persistStorageIntegrationIfNeeded( + @Nonnull PolarisCallContext callCtx, + @Nonnull PolarisBaseEntity entity, + @Nullable PolarisStorageIntegration storageIntegration) { + // Noop - no clue what this shall do!? + } + + @Nullable + @Override + public + PolarisStorageIntegration createStorageIntegration( + @Nonnull PolarisCallContext callCtx, + long catalogId, + long entityId, + PolarisStorageConfigurationInfo polarisStorageConfigurationInfo) { + return storageIntegrationProvider.getStorageIntegrationForConfig( + polarisStorageConfigurationInfo); + } + + CreatePrincipalResult createPrincipal( + PolarisBaseEntity principal, RootCredentialsSet rootCredentialsSet) { + LOGGER.atDebug().addArgument(() -> logEntityInfo(principal)).log("createPrincipal {}"); + + return performPrincipalChange( + CreatePrincipalResult.class, + (state, ref, byName, byId, byClientId) -> { + var principalName = principal.getName(); + var principalId = principal.getId(); + var nameKey = IndexKey.key(principalName); + var persistence = state.persistence(); + + var existingPrincipal = + Optional.ofNullable(byName.get(nameKey)) + .map(objRef -> persistence.fetch(objRef, PrincipalObj.class)); + if (existingPrincipal.isPresent()) { + var existing = existingPrincipal.get(); + var secrets = principalObjToPolarisPrincipalSecrets(existing); + var forComparison = objForChangeComparison(principal, Optional.of(secrets), existing); + return new ChangeResult.NoChange<>( + existing.equals(forComparison) + ? new CreatePrincipalResult(principal, secrets) + : new CreatePrincipalResult( + BaseResult.ReturnStatus.ENTITY_ALREADY_EXISTS, null)); + } + + LOGGER.debug("Creating principal '{}' ...", principalName); + + PolarisPrincipalSecrets newPrincipalSecrets; + while (true) { + newPrincipalSecrets = + secretsGenerator(rootCredentialsSet).produceSecrets(principalName, principalId); + var newClientId = newPrincipalSecrets.getPrincipalClientId(); + if (byClientId.get(IndexKey.key(newClientId)) == null) { + LOGGER.debug("Generated secrets for principal '{}' ...", principalName); + break; + } + } + + var now = persistence.currentInstant(); + // Map from the given entity to retain both the properties and internal-properties bags + // (for example, PRINCIPAL_CREDENTIAL_ROTATION_REQUIRED_STATE) + var updatedPrincipalBuilder = + mapToObj(principal, Optional.of(newPrincipalSecrets)) + .name(principalName) + .stableId(principalId) + .entityVersion(1) + .createTimestamp(now) + .updateTimestamp(now) + .id(persistence.generateId()); + var updatedPrincipal = updatedPrincipalBuilder.build(); + + var updatedPrincipalObjRef = objRef(updatedPrincipal); + byClientId.put( + IndexKey.key(newPrincipalSecrets.getPrincipalClientId()), updatedPrincipalObjRef); + byName.put(nameKey, updatedPrincipalObjRef); + byId.put(IndexKey.key(principalId), nameKey); + + state.writeOrReplace("principal", updatedPrincipal); + + // return those + return new ChangeResult.CommitChange<>( + new CreatePrincipalResult( + mapToEntity(updatedPrincipal, 0L), + principalObjToPolarisPrincipalSecrets( + (PrincipalObj) updatedPrincipal, newPrincipalSecrets))); + }); + } + + @Override + public void deletePrincipalSecrets( + @Nonnull PolarisCallContext callCtx, @Nonnull String clientId, long principalId) { + throw useMetaStoreManager("deletePrincipalSecrets"); + } + + @Nullable + @Override + public PolarisPrincipalSecrets rotatePrincipalSecrets( + @Nonnull PolarisCallContext callCtx, + @Nonnull String clientId, + long principalId, + boolean reset, + @Nonnull String oldSecretHash) { + throw useMetaStoreManager("rotatePrincipalSecrets"); + } + + @FunctionalInterface + interface SecretsUpdater { + R update(PrincipalObj principalObj, PrincipalObj.Builder principalObjBuilder); + } + + static class PrincipalNotFoundException extends RuntimeException {} + + R updatePrincipalSecrets( + Class resultType, String logInfo, long principalId, SecretsUpdater updater) { + LOGGER.debug("updatePrincipalSecrets ({}), principalId: {}", logInfo, principalId); + + return performPrincipalChange( + resultType, + (state, ref, byName, byId, byClientId) -> { + var principalIdName = byId.get(IndexKey.key(principalId)); + if (principalIdName == null) { + throw new PrincipalNotFoundException(); + } + var principalObjRef = byName.get(principalIdName); + if (principalObjRef == null) { + throw new PrincipalNotFoundException(); + } + + var persistence = state.persistence(); + var principal = persistence.fetch(principalObjRef, PrincipalObj.class); + if (principal == null) { + throw new PrincipalNotFoundException(); + } + + var updatedPrincipalBuilder = + PrincipalObj.builder() + .from(principal) + .id(persistence.generateId()) + .updateTimestamp(persistence.currentInstant()); + + var apiResult = updater.update(principal, updatedPrincipalBuilder); + + var updatedPrincipal = updatedPrincipalBuilder.build(); + + ObjRef updatedPrincipalObjRef = objRef(updatedPrincipal); + byName.put(IndexKey.key(updatedPrincipal.name()), updatedPrincipalObjRef); + + principal.clientId().map(IndexKey::key).ifPresent(byClientId::remove); + updatedPrincipal + .clientId() + .ifPresent( + clientId -> { + var clientIdKey = IndexKey.key(clientId); + byClientId.put(clientIdKey, updatedPrincipalObjRef); + }); + + state.writeOrReplace("principal", updatedPrincipal); + + return new ChangeResult.CommitChange<>(apiResult); + }); + } + + @Nonnull + @Override + public PolarisPrincipalSecrets generateNewPrincipalSecrets( + @Nonnull PolarisCallContext callCtx, @Nonnull String principalName, long principalId) { + LOGGER.debug( + "generateNewPrincipalSecrets principalName: {}, principalId: {}", + principalName, + principalId); + + return performPrincipalChange( + PolarisPrincipalSecrets.class, + (state, ref, byName, byId, byClientId) -> { + var nameKey = IndexKey.key(principalName); + var principalObjRef = byName.get(nameKey); + + var pers = state.persistence(); + var existingPrincipal = + Optional.ofNullable(principalObjRef) + .map(objRef -> pers.fetch(objRef, PrincipalObj.class)); + + checkState( + existingPrincipal.isEmpty() || principalId == existingPrincipal.get().stableId(), + "Principal id mismatch"); + + // generate new secrets + PolarisPrincipalSecrets newPrincipalSecrets; + while (true) { + newPrincipalSecrets = secretsGenerator(null).produceSecrets(principalName, principalId); + var newClientId = newPrincipalSecrets.getPrincipalClientId(); + if (byClientId.get(IndexKey.key(newClientId)) == null) { + break; + } + } + + var updatedPrincipalBuilder = PrincipalObj.builder(); + existingPrincipal.ifPresent(updatedPrincipalBuilder::from); + var now = persistence.currentInstant(); + if (existingPrincipal.isEmpty()) { + updatedPrincipalBuilder + .name(principalName) + .stableId(principalId) + .entityVersion(1) + .createTimestamp(now); + } + updatedPrincipalBuilder + .id(generateNewId()) + .updateTimestamp(now) + .clientId(newPrincipalSecrets.getPrincipalClientId()) + .mainSecretHash(newPrincipalSecrets.getMainSecretHash()) + .secondarySecretHash(newPrincipalSecrets.getSecondarySecretHash()) + .secretSalt(newPrincipalSecrets.getSecretSalt()); + var updatedPrincipal = updatedPrincipalBuilder.build(); + + existingPrincipal + .flatMap(PrincipalObj::clientId) + .map(IndexKey::key) + .ifPresent(byClientId::remove); + var updatedPrincipalObjRef = objRef(updatedPrincipal); + updatedPrincipal + .clientId() + .ifPresent(c -> byClientId.put(IndexKey.key(c), updatedPrincipalObjRef)); + byName.put(nameKey, updatedPrincipalObjRef); + byId.put(IndexKey.key(principalId), nameKey); + + state.writeOrReplace("principal", updatedPrincipal); + + // return those + return new ChangeResult.CommitChange<>(newPrincipalSecrets); + }); + } + + private PrincipalSecretsGenerator secretsGenerator( + @Nullable RootCredentialsSet rootCredentialsSet) { + if (rootCredentialsSet != null) { + var realmId = this.persistence.realmId(); + return PrincipalSecretsGenerator.bootstrap(realmId, rootCredentialsSet); + } else { + return PrincipalSecretsGenerator.RANDOM_SECRETS; + } + } + + @Nullable + @Override + public PolarisPrincipalSecrets loadPrincipalSecrets( + @Nonnull PolarisCallContext callCtx, @Nonnull String clientId) { + return loadPrincipalSecrets(clientId); + } + + PolarisPrincipalSecrets loadPrincipalSecrets(@Nonnull String clientId) { + LOGGER.debug("loadPrincipalSecrets clientId: {}", clientId); + + var key = IndexKey.key(clientId); + + return memoizedIndexedAccess + .indexedAccess(0L, PolarisEntityType.PRINCIPAL.getCode()) + .refObj() + .map(PrincipalsObj.class::cast) + .map(PrincipalsObj::byClientId) + .map(c -> c.indexForRead(persistence, OBJ_REF_SERIALIZER)) + .map(i -> i.get(key)) + .map(objRef -> persistence.fetch(objRef, PrincipalObj.class)) + .map(TypeMapping::principalObjToPolarisPrincipalSecrets) + .orElse(null); + } + + @Nullable + @Override + public PolarisPrincipalSecrets storePrincipalSecrets( + @Nonnull PolarisCallContext callCtx, + long principalId, + @Nonnull String resolvedClientId, + String customClientSecret) { + throw useMetaStoreManager("storePrincipalSecrets"); + } + + @Override + public void writeEvents(@Nonnull List events) { + throw useMetaStoreManager("writeEvents"); + } + + @Override + public void deleteAll(@Nonnull PolarisCallContext callCtx) { + throw unimplemented(); + } + + @Override + public BasePersistence detach() { + return new PersistenceMetaStore( + persistence, privileges, storageIntegrationProvider, diagnostics); + } + + private static UnsupportedOperationException unimplemented() { + var ex = new UnsupportedOperationException("IMPLEMENT ME"); + LOGGER.error("Unsupported function call", ex); + return ex; + } + + private static UnsupportedOperationException useMetaStoreManager(String function) { + var ex = + new UnsupportedOperationException( + "Operation not supported - use PolarisMetaStoreManager." + function + "()"); + LOGGER.error("Unsupported function call", ex); + return ex; + } + + private static void initializeCatalogIfNecessary(Persistence persistence, CatalogObj catalog) { + persistence.createReferencesSilent(catalogReferenceNames(catalog.stableId())); + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PersistenceMetaStoreManager.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PersistenceMetaStoreManager.java new file mode 100644 index 0000000000..3c8f9d1e75 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PersistenceMetaStoreManager.java @@ -0,0 +1,857 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.mapToEntity; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.mapToEntityNameLookupRecord; +import static org.apache.polaris.persistence.nosql.metastore.TypeMapping.principalObjToPolarisPrincipalSecrets; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.time.Clock; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; +import org.apache.polaris.core.PolarisCallContext; +import org.apache.polaris.core.entity.LocationBasedEntity; +import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.core.entity.PolarisEntity; +import org.apache.polaris.core.entity.PolarisEntityConstants; +import org.apache.polaris.core.entity.PolarisEntityCore; +import org.apache.polaris.core.entity.PolarisEntityId; +import org.apache.polaris.core.entity.PolarisEntitySubType; +import org.apache.polaris.core.entity.PolarisEntityType; +import org.apache.polaris.core.entity.PolarisEvent; +import org.apache.polaris.core.entity.PolarisGrantRecord; +import org.apache.polaris.core.entity.PolarisPrincipalSecrets; +import org.apache.polaris.core.entity.PolarisPrivilege; +import org.apache.polaris.core.entity.PolarisTaskConstants; +import org.apache.polaris.core.entity.PrincipalEntity; +import org.apache.polaris.core.persistence.PolarisMetaStoreManager; +import org.apache.polaris.core.persistence.PolarisObjectMapperUtil; +import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet; +import org.apache.polaris.core.persistence.dao.entity.BaseResult; +import org.apache.polaris.core.persistence.dao.entity.ChangeTrackingResult; +import org.apache.polaris.core.persistence.dao.entity.CreateCatalogResult; +import org.apache.polaris.core.persistence.dao.entity.CreatePrincipalResult; +import org.apache.polaris.core.persistence.dao.entity.DropEntityResult; +import org.apache.polaris.core.persistence.dao.entity.EntitiesResult; +import org.apache.polaris.core.persistence.dao.entity.EntityResult; +import org.apache.polaris.core.persistence.dao.entity.EntityWithPath; +import org.apache.polaris.core.persistence.dao.entity.GenerateEntityIdResult; +import org.apache.polaris.core.persistence.dao.entity.ListEntitiesResult; +import org.apache.polaris.core.persistence.dao.entity.LoadGrantsResult; +import org.apache.polaris.core.persistence.dao.entity.LoadPolicyMappingsResult; +import org.apache.polaris.core.persistence.dao.entity.PolicyAttachmentResult; +import org.apache.polaris.core.persistence.dao.entity.PrincipalSecretsResult; +import org.apache.polaris.core.persistence.dao.entity.PrivilegeResult; +import org.apache.polaris.core.persistence.dao.entity.ResolvedEntitiesResult; +import org.apache.polaris.core.persistence.dao.entity.ResolvedEntityResult; +import org.apache.polaris.core.persistence.dao.entity.ScopedCredentialsResult; +import org.apache.polaris.core.persistence.pagination.Page; +import org.apache.polaris.core.persistence.pagination.PageToken; +import org.apache.polaris.core.policy.PolicyEntity; +import org.apache.polaris.core.policy.PolicyType; +import org.apache.polaris.core.storage.PolarisStorageConfigurationInfo; + +record PersistenceMetaStoreManager( + Supplier purgeRealm, + RootCredentialsSet rootCredentialsSet, + Supplier metaStoreSupplier, + Clock clock) + implements PolarisMetaStoreManager { + + PersistenceMetaStore ms() { + return metaStoreSupplier.get(); + } + + // Realms + + @Nonnull + @Override + public BaseResult bootstrapPolarisService(@Nonnull PolarisCallContext callCtx) { + bootstrapPolarisServiceInternal(); + return new BaseResult(BaseResult.ReturnStatus.SUCCESS); + } + + Optional bootstrapPolarisServiceInternal() { + // This function is idempotent, already existing entities will not be created again. + + var ms = ms(); + + // Create the root-container, if not already present + var rootContainer = + ms.lookupRoot() + .orElseGet( + () -> { + var newRoot = + new PolarisBaseEntity( + PolarisEntityConstants.getNullId(), + PolarisEntityConstants.getRootEntityId(), + PolarisEntityType.ROOT, + PolarisEntitySubType.NULL_SUBTYPE, + PolarisEntityConstants.getRootEntityId(), + PolarisEntityConstants.getRootContainerName()); + ms.createEntity(newRoot); + return newRoot; + }); + + // Create the root-principal, if not already present + var rootPrincipal = + ms.lookupEntityByName( + 0L, + 0L, + PolarisEntityType.PRINCIPAL.getCode(), + PolarisEntityConstants.getRootPrincipalName()); + var createPrincipalResult = Optional.empty(); + if (rootPrincipal == null) { + var rootPrincipalId = ms.generateNewId(); + rootPrincipal = + new PolarisBaseEntity( + PolarisEntityConstants.getNullId(), + rootPrincipalId, + PolarisEntityType.PRINCIPAL, + PolarisEntitySubType.NULL_SUBTYPE, + PolarisEntityConstants.getRootEntityId(), + PolarisEntityConstants.getRootPrincipalName()); + + createPrincipalResult = Optional.of(ms.createPrincipal(rootPrincipal, rootCredentialsSet)); + } + + // Create the service-admin principal-role, if not already present + var serviceAdminPrincipalRole = + ms.lookupEntityByName( + 0L, + 0L, + PolarisEntityType.PRINCIPAL_ROLE.getCode(), + PolarisEntityConstants.getNameOfPrincipalServiceAdminRole()); + if (serviceAdminPrincipalRole == null) { + // now create the account admin principal role + var serviceAdminPrincipalRoleId = ms.generateNewId(); + serviceAdminPrincipalRole = + new PolarisBaseEntity( + PolarisEntityConstants.getNullId(), + serviceAdminPrincipalRoleId, + PolarisEntityType.PRINCIPAL_ROLE, + PolarisEntitySubType.NULL_SUBTYPE, + PolarisEntityConstants.getRootEntityId(), + PolarisEntityConstants.getNameOfPrincipalServiceAdminRole()); + ms.createEntity(serviceAdminPrincipalRole); + } + + // Persisting already existing grants is an idempotent operation + ms.persistGrantsOrRevokes( + 0L, + true, + // we also need to grant usage on the account-admin principal to the principal + new Grant(serviceAdminPrincipalRole, rootPrincipal, PolarisPrivilege.PRINCIPAL_ROLE_USAGE), + // grant SERVICE_MANAGE_ACCESS on the rootContainer to the serviceAdminPrincipalRole + new Grant( + rootContainer, serviceAdminPrincipalRole, PolarisPrivilege.SERVICE_MANAGE_ACCESS)); + + return createPrincipalResult; + } + + @Nonnull + @Override + public BaseResult purge(@Nonnull PolarisCallContext callCtx) { + return purgeRealm.get(); + } + + // Catalog + + @Nonnull + @Override + public CreateCatalogResult createCatalog( + @Nonnull PolarisCallContext callCtx, + @Nonnull PolarisBaseEntity catalog, + @Nonnull List principalRoles) { + var ms = ms(); + + var internalProp = catalog.getInternalPropertiesAsMap(); + var integrationIdentifierOrId = + internalProp.get(PolarisEntityConstants.getStorageIntegrationIdentifierPropertyName()); + var storageConfigInfoStr = + internalProp.get(PolarisEntityConstants.getStorageConfigInfoPropertyName()); + + var integration = + // storageConfigInfo's presence is needed to create a storage integration, + // and the catalog should not have an internal property of storage identifier or id yet + (storageConfigInfoStr != null && integrationIdentifierOrId == null) + ? ms.createStorageIntegration( + callCtx, + catalog.getCatalogId(), + catalog.getId(), + PolarisStorageConfigurationInfo.deserialize(storageConfigInfoStr)) + : null; + + var prRoles = principalRoles.stream().map(PolarisBaseEntity.class::cast).toList(); + + return ms.createCatalog(callCtx, catalog, prRoles, integration); + } + + // Generic entities + + @Nonnull + @Override + public EntityResult createEntityIfNotExists( + @Nonnull PolarisCallContext callCtx, + @Nullable List catalogPath, + @Nonnull PolarisBaseEntity entity) { + var ms = ms(); + return ms.createEntity(entity); + } + + @SuppressWarnings("unchecked") + @Nonnull + @Override + public EntitiesResult createEntitiesIfNotExist( + @Nonnull PolarisCallContext callCtx, + @Nullable List catalogPath, + @Nonnull List entities) { + var ms = ms(); + return ms.createEntities((List) entities); + } + + @Nonnull + @Override + public EntityResult updateEntityPropertiesIfNotChanged( + @Nonnull PolarisCallContext callCtx, + @Nullable List catalogPath, + @Nonnull PolarisBaseEntity entity) { + var ms = ms(); + return ms.updateEntity(entity); + } + + @Nonnull + @Override + public EntitiesResult updateEntitiesPropertiesIfNotChanged( + @Nonnull PolarisCallContext callCtx, @Nonnull List entities) { + var ms = ms(); + return ms.updateEntities(entities); + } + + @Nonnull + @Override + public EntityResult renameEntity( + @Nonnull PolarisCallContext callCtx, + @Nullable List catalogPath, + @Nonnull PolarisBaseEntity entityToRename, + @Nullable List newCatalogPath, + @Nonnull PolarisEntity renamedEntity) { + var ms = ms(); + if (newCatalogPath != null && !newCatalogPath.isEmpty()) { + var last = newCatalogPath.getLast(); + // At least BasePolarisMetaStoreManagerTest comes with the wrong parentId in renamedEntity + if (renamedEntity.getParentId() != last.getId()) { + renamedEntity = new PolarisEntity.Builder(renamedEntity).setParentId(last.getId()).build(); + } + } + return ms.updateEntity(renamedEntity); + } + + @Nonnull + @Override + public DropEntityResult dropEntityIfExists( + @Nonnull PolarisCallContext callCtx, + @Nullable List catalogPath, + @Nonnull PolarisBaseEntity entityToDrop, + @Nullable Map cleanupProperties, + boolean cleanup) { + return ms().dropEntity(entityToDrop, cleanupProperties, cleanup); + } + + @Nonnull + @Override + public EntityResult readEntityByName( + @Nonnull PolarisCallContext callCtx, + @Nullable List catalogPath, + @Nonnull PolarisEntityType entityType, + @Nonnull PolarisEntitySubType entitySubType, + @Nonnull String name) { + return readEntityByName(catalogPath, entityType, name); + } + + EntityResult readEntityByName( + List catalogPath, PolarisEntityType entityType, String name) { + var ms = ms(); + var catalogId = 0L; + var parentId = 0L; + if (catalogPath != null && !catalogPath.isEmpty()) { + catalogId = catalogPath.getFirst().getId(); + parentId = catalogPath.getLast().getId(); + } + var entity = ms.lookupEntityByName(catalogId, parentId, entityType.getCode(), name); + return entity != null + ? new EntityResult(entity) + : new EntityResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null); + } + + @Nonnull + @Override + public Page listFullEntities( + @Nonnull PolarisCallContext callCtx, + @Nullable List catalogPath, + @Nonnull PolarisEntityType entityType, + @Nonnull PolarisEntitySubType entitySubType, + @Nonnull PageToken pageToken) { + var catalogStableId = + (catalogPath != null && !catalogPath.isEmpty()) ? catalogPath.getFirst().getId() : 0L; + + var parentId = + (catalogPath != null && catalogPath.size() > 1) ? catalogPath.getLast().getId() : 0L; + + return ms().fetchEntitiesAsPage( + catalogStableId, + parentId, + entityType, + entitySubType, + pageToken, + objBase -> mapToEntity(objBase, catalogStableId), + entity -> true, + Function.identity()); + } + + @Nonnull + @Override + public ListEntitiesResult listEntities( + @Nonnull PolarisCallContext callCtx, + @Nullable List catalogPath, + @Nonnull PolarisEntityType entityType, + @Nonnull PolarisEntitySubType entitySubType, + @Nonnull PageToken pageToken) { + var catalogStableId = + (catalogPath != null && !catalogPath.isEmpty()) ? catalogPath.getFirst().getId() : 0L; + + var parentId = + (catalogPath != null && catalogPath.size() > 1) ? catalogPath.getLast().getId() : 0L; + + var page = + ms().fetchEntitiesAsPage( + catalogStableId, + parentId, + entityType, + entitySubType, + pageToken, + objBase -> mapToEntityNameLookupRecord(objBase, catalogStableId), + entity -> true, + Function.identity()); + + return new ListEntitiesResult(page); + } + + @Nonnull + @Override + public GenerateEntityIdResult generateNewEntityId(@Nonnull PolarisCallContext callCtx) { + return new GenerateEntityIdResult(metaStoreSupplier.get().generateNewId(callCtx)); + } + + @Nonnull + @Override + public ResolvedEntitiesResult loadResolvedEntities( + @Nonnull PolarisCallContext callCtx, + @Nonnull PolarisEntityType entityType, + @Nonnull List entityIds) { + throw new UnsupportedOperationException("Not implemented"); + } + + @Nonnull + @Override + public ResolvedEntityResult loadResolvedEntityById( + @Nonnull PolarisCallContext callCtx, + long entityCatalogId, + long entityId, + PolarisEntityType entityType) { + var ms = ms(); + + // load that entity + PolarisBaseEntity entity = + ms.lookupEntity(callCtx, entityCatalogId, entityId, entityType.getCode()); + + // if entity not found, return null + if (entity == null) { + return new ResolvedEntityResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null); + } + + // load the grant records + var grantRecords = ms.allGrantRecords(entity); + + // return the result + return new ResolvedEntityResult(entity, entity.getGrantRecordsVersion(), grantRecords); + } + + @Nonnull + @Override + public ResolvedEntityResult loadResolvedEntityByName( + @Nonnull PolarisCallContext callCtx, + long entityCatalogId, + long parentId, + @Nonnull PolarisEntityType entityType, + @Nonnull String entityName) { + var ms = ms(); + + // load that entity + var entity = + ms.lookupEntityByName(callCtx, entityCatalogId, parentId, entityType.getCode(), entityName); + + // null if entity not found + if (entity == null) { + return new ResolvedEntityResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null); + } + + // load the grant records + var grantRecords = ms.allGrantRecords(entity); + + // return the result + return new ResolvedEntityResult(entity, entity.getGrantRecordsVersion(), grantRecords); + } + + @Nonnull + @Override + public ResolvedEntityResult refreshResolvedEntity( + @Nonnull PolarisCallContext callCtx, + int entityVersion, + int entityGrantRecordsVersion, + @Nonnull PolarisEntityType entityType, + long entityCatalogId, + long entityId) { + return loadResolvedEntityById(callCtx, entityCatalogId, entityId, entityType); + } + + // Principals & Polaris GrantManager + + @Nonnull + @Override + public PrivilegeResult grantUsageOnRoleToGrantee( + @Nonnull PolarisCallContext callCtx, + @Nullable PolarisEntityCore catalog, + @Nonnull PolarisEntityCore role, + @Nonnull PolarisEntityCore grantee) { + var privilege = + (grantee.getType() == PolarisEntityType.PRINCIPAL_ROLE) + ? PolarisPrivilege.CATALOG_ROLE_USAGE + : PolarisPrivilege.PRINCIPAL_ROLE_USAGE; + + return grantPrivilegeOnSecurableToRole(callCtx, grantee, null, role, privilege); + } + + @Nonnull + @Override + public PrivilegeResult revokeUsageOnRoleFromGrantee( + @Nonnull PolarisCallContext callCtx, + @Nullable PolarisEntityCore catalog, + @Nonnull PolarisEntityCore role, + @Nonnull PolarisEntityCore grantee) { + var privilege = + (grantee.getType() == PolarisEntityType.PRINCIPAL_ROLE) + ? PolarisPrivilege.CATALOG_ROLE_USAGE + : PolarisPrivilege.PRINCIPAL_ROLE_USAGE; + + return revokePrivilegeOnSecurableFromRole(callCtx, grantee, null, role, privilege); + } + + @Nonnull + @Override + public PrivilegeResult grantPrivilegeOnSecurableToRole( + @Nonnull PolarisCallContext callCtx, + @Nonnull PolarisEntityCore grantee, + @Nullable List catalogPath, + @Nonnull PolarisEntityCore securable, + @Nonnull PolarisPrivilege privilege) { + return grantOrRevokePrivilegeOnSecurableToRole( + true, grantee, catalogPath, securable, privilege); + } + + @Nonnull + @Override + public PrivilegeResult revokePrivilegeOnSecurableFromRole( + @Nonnull PolarisCallContext callCtx, + @Nonnull PolarisEntityCore grantee, + @Nullable List catalogPath, + @Nonnull PolarisEntityCore securable, + @Nonnull PolarisPrivilege privilege) { + return grantOrRevokePrivilegeOnSecurableToRole( + false, grantee, catalogPath, securable, privilege); + } + + private PrivilegeResult grantOrRevokePrivilegeOnSecurableToRole( + boolean grant, + PolarisEntityCore grantee, + List catalogPath, + PolarisEntityCore securable, + PolarisPrivilege privilege) { + var ms = ms(); + var catalogId = + catalogPath != null && !catalogPath.isEmpty() ? catalogPath.getFirst().getId() : 0L; + if (!ms.persistGrantsOrRevokes(catalogId, grant, new Grant(securable, grantee, privilege))) { + return new PrivilegeResult(BaseResult.ReturnStatus.GRANT_NOT_FOUND, ""); + } + + var grantRecord = + new PolarisGrantRecord( + securable.getCatalogId(), + securable.getId(), + grantee.getCatalogId(), + grantee.getId(), + privilege.getCode()); + return new PrivilegeResult(grantRecord); + } + + @Nonnull + @Override + public LoadGrantsResult loadGrantsOnSecurable( + @Nonnull PolarisCallContext callCtx, PolarisEntityCore securable) { + var ms = ms(); + return ms.loadGrants( + securable.getCatalogId(), securable.getId(), securable.getTypeCode(), true); + } + + @Nonnull + @Override + public LoadGrantsResult loadGrantsToGrantee( + @Nonnull PolarisCallContext callCtx, PolarisEntityCore grantee) { + var ms = ms(); + return ms.loadGrants(grantee.getCatalogId(), grantee.getId(), grantee.getTypeCode(), false); + } + + @Nonnull + @Override + public ChangeTrackingResult loadEntitiesChangeTracking( + @Nonnull PolarisCallContext callCtx, @Nonnull List entityIds) { + throw new UnsupportedOperationException("No change tracking - do not call this function"); + } + + @Nonnull + @Override + public EntityResult loadEntity( + @Nonnull PolarisCallContext callCtx, + long entityCatalogId, + long entityId, + @Nonnull PolarisEntityType entityType) { + var ms = ms(); + var entity = ms.lookupEntity(callCtx, entityCatalogId, entityId, entityType.getCode()); + return (entity != null) + ? new EntityResult(entity) + : new EntityResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null); + } + + @Override + public + Optional> hasOverlappingSiblings( + @Nonnull PolarisCallContext callContext, T entity) { + return Optional.of(ms().hasOverlappingSiblings(entity)); + } + + @Nonnull + @Override + public EntitiesResult loadTasks( + @Nonnull PolarisCallContext callCtx, String executorId, PageToken pageToken) { + var ms = ms(); + + // find all available tasks + var availableTasks = + ms.listFullEntities( + callCtx, + PolarisEntityConstants.getRootEntityId(), + PolarisEntityConstants.getRootEntityId(), + PolarisEntityType.TASK, + PolarisEntitySubType.ANY_SUBTYPE, + entity -> { + var taskState = PolarisObjectMapperUtil.parseTaskState(entity); + long taskAgeTimeout = + callCtx + .getRealmConfig() + .getConfig( + PolarisTaskConstants.TASK_TIMEOUT_MILLIS_CONFIG, + PolarisTaskConstants.TASK_TIMEOUT_MILLIS); + return taskState == null + || taskState.executor == null + || clock.millis() - taskState.lastAttemptStartTime > taskAgeTimeout; + }, + Function.identity(), + PageToken.readEverything()); + + // TODO the following loop is NOT a "load" - it's a mutation over all loaded tasks !! + + availableTasks + .items() + .forEach( + task -> { + var newTask = new PolarisBaseEntity.Builder(task); + var properties = PolarisObjectMapperUtil.deserializeProperties(task.getProperties()); + properties.put(PolarisTaskConstants.LAST_ATTEMPT_EXECUTOR_ID, executorId); + properties.put( + PolarisTaskConstants.LAST_ATTEMPT_START_TIME, String.valueOf(clock.millis())); + properties.put( + PolarisTaskConstants.ATTEMPT_COUNT, + String.valueOf( + Integer.parseInt( + properties.getOrDefault(PolarisTaskConstants.ATTEMPT_COUNT, "0")) + + 1)); + newTask.entityVersion(task.getEntityVersion() + 1); + newTask.properties(PolarisObjectMapperUtil.serializeProperties(properties)); + ms.updateEntity(newTask.build()); + }); + return new EntitiesResult(Page.fromItems(availableTasks.items())); + } + + // Policies + + @Nonnull + @Override + public PolicyAttachmentResult attachPolicyToEntity( + @Nonnull PolarisCallContext callCtx, + @Nonnull List targetCatalogPath, + @Nonnull PolarisEntityCore target, + @Nonnull List policyCatalogPath, + @Nonnull PolicyEntity policy, + Map parameters) { + if (parameters == null) { + parameters = Map.of(); + } + var ms = ms(); + return ms.attachDetachPolicyOnEntity( + policy.getCatalogId(), + policy.getId(), + policy.getPolicyType(), + target.getCatalogId(), + target.getId(), + true, + parameters); + } + + @Nonnull + @Override + public PolicyAttachmentResult detachPolicyFromEntity( + @Nonnull PolarisCallContext callCtx, + @Nonnull List catalogPath, + @Nonnull PolarisEntityCore target, + @Nonnull List policyCatalogPath, + @Nonnull PolicyEntity policy) { + var ms = ms(); + return ms.attachDetachPolicyOnEntity( + policy.getCatalogId(), + policy.getId(), + policy.getPolicyType(), + target.getCatalogId(), + target.getId(), + false, + Map.of()); + } + + @Nonnull + @Override + public LoadPolicyMappingsResult loadPoliciesOnEntity( + @Nonnull PolarisCallContext callCtx, @Nonnull PolarisEntityCore target) { + var ms = ms(); + return ms.loadPoliciesOnEntity( + target.getType(), target.getCatalogId(), target.getId(), Optional.empty()); + } + + @Nonnull + @Override + public LoadPolicyMappingsResult loadPoliciesOnEntityByType( + @Nonnull PolarisCallContext callCtx, + @Nonnull PolarisEntityCore target, + @Nonnull PolicyType policyType) { + var ms = ms(); + return ms.loadPoliciesOnEntity( + target.getType(), target.getCatalogId(), target.getId(), Optional.of(policyType)); + } + + // Principals & PolarisSecretsManager + + @Nonnull + @Override + public CreatePrincipalResult createPrincipal( + @Nonnull PolarisCallContext callCtx, @Nonnull PrincipalEntity principal) { + return ms().createPrincipal(principal, rootCredentialsSet); + } + + @Nonnull + @Override + public PrincipalSecretsResult loadPrincipalSecrets( + @Nonnull PolarisCallContext callCtx, @Nonnull String clientId) { + var ms = ms(); + + var secrets = ms.loadPrincipalSecrets(clientId); + + return (secrets == null) + ? new PrincipalSecretsResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null) + : new PrincipalSecretsResult(secrets); + } + + @Nonnull + @Override + public PrincipalSecretsResult rotatePrincipalSecrets( + @Nonnull PolarisCallContext callCtx, + @Nonnull String clientId, + long principalId, + boolean reset, + @Nonnull String oldSecretHash) { + return rotatePrincipalSecrets(principalId, reset, oldSecretHash); + } + + PrincipalSecretsResult rotatePrincipalSecrets( + long principalId, boolean reset, String oldSecretHash) { + try { + return ms().updatePrincipalSecrets( + PrincipalSecretsResult.class, + "rotatePrincipalSecrets", + principalId, + (principal, updatedPrincipalBuilder) -> { + var principalSecrets = principalObjToPolarisPrincipalSecrets(principal); + + // rotate the secrets + principalSecrets.rotateSecrets(oldSecretHash); + if (reset) { + principalSecrets.rotateSecrets(principalSecrets.getMainSecretHash()); + } + + updatedPrincipalBuilder + .entityVersion(principal.entityVersion() + 1) + .credentialRotationRequired(reset && !principal.credentialRotationRequired()) + .clientId(principalSecrets.getPrincipalClientId()) + .mainSecretHash(principalSecrets.getMainSecretHash()) + .secondarySecretHash(principalSecrets.getSecondarySecretHash()) + .secretSalt(principalSecrets.getSecretSalt()); + + return new PrincipalSecretsResult(principalSecrets); + }); + } catch (PersistenceMetaStore.PrincipalNotFoundException ignore) { + return new PrincipalSecretsResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null); + } + } + + @Nonnull + @Override + public PrincipalSecretsResult resetPrincipalSecrets( + @Nonnull PolarisCallContext callCtx, + long principalId, + @Nonnull String resolvedClientId, + String customClientSecret) { + try { + return ms().updatePrincipalSecrets( + PrincipalSecretsResult.class, + "resetPrincipalSecrets", + principalId, + (principal, updatedPrincipalBuilder) -> { + var principalSecrets = + new PolarisPrincipalSecrets( + principal.stableId(), resolvedClientId, customClientSecret); + updatedPrincipalBuilder + .entityVersion(principal.entityVersion() + 1) + .clientId(principalSecrets.getPrincipalClientId()) + .mainSecretHash(principalSecrets.getMainSecretHash()) + .secondarySecretHash(principalSecrets.getSecondarySecretHash()) + .secretSalt(principalSecrets.getSecretSalt()); + + return new PrincipalSecretsResult(principalSecrets); + }); + } catch (PersistenceMetaStore.PrincipalNotFoundException ignore) { + return new PrincipalSecretsResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null); + } + } + + @Override + public void deletePrincipalSecrets( + @Nonnull PolarisCallContext callCtx, @Nonnull String clientId, long principalId) { + ms().updatePrincipalSecrets( + String.class, + "deletePrincipalSecrets", + principalId, + (principal, updatedPrincipalBuilder) -> { + // Do NOT update the entityVersion + updatedPrincipalBuilder + .clientId(Optional.empty()) + .secretSalt(Optional.empty()) + .mainSecretHash(Optional.empty()) + .secondarySecretHash(Optional.empty()); + return ""; // need some non-null return value + }); + } + + // PolarisCredentialVendor + + @Nonnull + @Override + public ScopedCredentialsResult getSubscopedCredsForEntity( + @Nonnull PolarisCallContext callCtx, + long catalogId, + long entityId, + @Nonnull PolarisEntityType entityType, + boolean allowListOperation, + @Nonnull Set allowedReadLocations, + @Nonnull Set allowedWriteLocations, + Optional refreshCredentialsEndpoint) { + + var ms = ms(); + + checkArgument( + !allowedReadLocations.isEmpty() || !allowedWriteLocations.isEmpty(), + "allowed_locations_to_subscope_is_required"); + + // reload the entity, error out if not found + var reloadedEntity = loadEntity(callCtx, catalogId, entityId, entityType); + if (reloadedEntity.getReturnStatus() != BaseResult.ReturnStatus.SUCCESS) { + return new ScopedCredentialsResult( + reloadedEntity.getReturnStatus(), reloadedEntity.getExtraInformation()); + } + + // get storage integration + var storageIntegration = ms.loadPolarisStorageIntegration(callCtx, reloadedEntity.getEntity()); + + // cannot be null + checkNotNull( + storageIntegration, + "storage_integration_not_exists, catalogId=%s, entityId=%s", + catalogId, + entityId); + + try { + var creds = + storageIntegration.getSubscopedCreds( + callCtx.getRealmConfig(), + allowListOperation, + allowedReadLocations, + allowedWriteLocations, + refreshCredentialsEndpoint); + return new ScopedCredentialsResult(creds); + } catch (Exception ex) { + return new ScopedCredentialsResult( + BaseResult.ReturnStatus.SUBSCOPE_CREDS_ERROR, ex.getMessage()); + } + } + + @Override + public boolean requiresEntityReload() { + return false; + } + + @Override + public void writeEvents( + @Nonnull PolarisCallContext callCtx, @Nonnull List polarisEvents) { + throw new UnsupportedOperationException("Events not supported in NoSQL persistence"); + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PersistenceMetaStoreManagerFactory.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PersistenceMetaStoreManagerFactory.java new file mode 100644 index 0000000000..cd12b045cd --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PersistenceMetaStoreManagerFactory.java @@ -0,0 +1,353 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static org.apache.polaris.persistence.nosql.coretypes.refs.References.realmReferenceNames; + +import io.smallrye.common.annotation.Identifier; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.time.Clock; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.polaris.core.PolarisDiagnostics; +import org.apache.polaris.core.config.RealmConfig; +import org.apache.polaris.core.context.RealmContext; +import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.core.entity.PolarisEntity; +import org.apache.polaris.core.entity.PolarisEntityConstants; +import org.apache.polaris.core.entity.PolarisEntityType; +import org.apache.polaris.core.entity.PolarisPrincipalSecrets; +import org.apache.polaris.core.persistence.BasePersistence; +import org.apache.polaris.core.persistence.MetaStoreManagerFactory; +import org.apache.polaris.core.persistence.PolarisMetaStoreManager; +import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet; +import org.apache.polaris.core.persistence.cache.EntityCache; +import org.apache.polaris.core.persistence.dao.entity.BaseResult; +import org.apache.polaris.core.persistence.dao.entity.CreatePrincipalResult; +import org.apache.polaris.core.persistence.dao.entity.PrincipalSecretsResult; +import org.apache.polaris.core.storage.PolarisStorageIntegrationProvider; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.RealmPersistenceFactory; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; +import org.apache.polaris.persistence.nosql.realms.api.RealmDefinition; +import org.apache.polaris.persistence.nosql.realms.api.RealmManagement; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +@Identifier("nosql") +class PersistenceMetaStoreManagerFactory implements MetaStoreManagerFactory { + private static final Logger LOGGER = + LoggerFactory.getLogger(PersistenceMetaStoreManagerFactory.class); + + private final Map realmPersistenceMap = new ConcurrentHashMap<>(); + private final RealmManagement realmManagement; + private final RealmPersistenceFactory realmPersistenceFactory; + private final Privileges privileges; + private final PolarisStorageIntegrationProvider storageIntegrationProvider; + private final Clock clock; + private final PolarisDiagnostics diagnostics; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + PersistenceMetaStoreManagerFactory( + RealmManagement realmManagement, + RealmPersistenceFactory realmPersistenceFactory, + Privileges privileges, + PolarisStorageIntegrationProvider storageIntegrationProvider, + Clock clock, + PolarisDiagnostics diagnostics) { + this.realmManagement = realmManagement; + this.realmPersistenceFactory = realmPersistenceFactory; + this.privileges = privileges; + this.storageIntegrationProvider = storageIntegrationProvider; + this.clock = clock; + this.diagnostics = diagnostics; + + try (var realms = realmManagement.list()) { + realms.forEach(realmDefinition -> LOGGER.info("Realm registered: {}", realmDefinition)); + } + } + + @Override + public Map bootstrapRealms( + Iterable realms, RootCredentialsSet rootCredentialsSet) { + Map results = new HashMap<>(); + + for (String realmId : realms) { + var existing = realmManagement.get(realmId); + if (existing.isPresent() && !existing.get().needsBootstrap()) { + LOGGER.debug("Realm '{}' is already fully bootstrapped.", realmId); + continue; + } + + var secretsResult = bootstrapRealm(realmId, rootCredentialsSet, existing); + results.put(realmId, secretsResult); + } + + return Map.copyOf(results); + } + + @Override + public Map purgeRealms(Iterable realms) { + var results = new HashMap(); + + for (var realm : realms) { + results.put(realm, purgeRealm(realm)); + } + + return Map.copyOf(results); + } + + @Override + public EntityCache getOrCreateEntityCache(RealmContext realmContext, RealmConfig realmConfig) { + // no `EntityCache` + return null; + } + + @Override + public BasePersistence getOrCreateSession(RealmContext realmContext) { + return newPersistenceMetaStore(initializedRealmPersistence(realmContext.getRealmIdentifier())); + } + + @Override + public PolarisMetaStoreManager getOrCreateMetaStoreManager(RealmContext realmContext) { + var realmId = realmContext.getRealmIdentifier(); + var persistence = initializedRealmPersistence(realmId); + + return new PersistenceMetaStoreManager( + () -> purgeRealm(realmId), null, () -> newPersistenceMetaStore(persistence), clock); + } + + private PersistenceMetaStore newPersistenceMetaStore(Persistence persistence) { + return new PersistenceMetaStore( + persistence, privileges, storageIntegrationProvider, diagnostics); + } + + private Persistence initializedRealmPersistence(String realmId) { + var persistence = realmPersistenceMap.get(realmId); + if (persistence != null) { + return persistence; + } + // This synchronization is there to prevent the CHM from locking and causing "strange" side + // effects. A naive "computeIfAbsent" with 'initializeIfNecessary' called from the mapping + // function could cause the CHM to lock for quite a long while. + synchronized (this) { + persistence = realmPersistenceMap.get(realmId); + if (persistence != null) { + return persistence; + } + + LOGGER.info("Checking realm '{}' on first use", realmId); + + var realmDesc = realmManagement.get(realmId); + checkArgument(realmDesc.isPresent(), "Realm '%s' does not exist", realmId); + checkArgument( + !realmDesc.get().needsBootstrap(), + "Realm '%s' has not been fully bootstrapped. Re-run the bootstrap admin command.", + realmId); + + persistence = buildRealmPersistence(realmId); + var ms = newPersistenceMetaStore(persistence); + ms.initializeCatalogsIfNecessary(); + realmPersistenceMap.put(realmId, persistence); + LOGGER.info("Done checking realm '{}'", realmId); + return persistence; + } + } + + private Persistence buildRealmPersistence(String realmId) { + return realmPersistenceFactory.newBuilder().realmId(realmId).build(); + } + + BaseResult purgeRealm(String realmId) { + while (true) { + var existingOptional = realmManagement.get(realmId); + if (existingOptional.isEmpty()) { + realmPersistenceMap.remove(realmId); + return new BaseResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND); + } + var existing = existingOptional.get(); + + var nextStatus = + switch (existing.status()) { + case CREATED, LOADING, INITIALIZING, INACTIVE -> RealmDefinition.RealmStatus.PURGING; + case ACTIVE -> RealmDefinition.RealmStatus.INACTIVE; + case PURGING -> + // TODO this state should really happen during maintenance!! + RealmDefinition.RealmStatus.PURGED; + case PURGED -> RealmDefinition.RealmStatus.PURGED; + }; + + var update = RealmDefinition.builder().from(existing).status(nextStatus).build(); + + var updated = realmManagement.update(existing, update); + + if (updated.status() == RealmDefinition.RealmStatus.PURGED) { + realmManagement.delete(updated); + break; + } + } + + realmPersistenceMap.remove(realmId); + + return new BaseResult(BaseResult.ReturnStatus.SUCCESS); + } + + /** + * Bootstrap the given realm using the given root credentials. + * + *

Repeated calls to this function for the same realm are safe, the outcome is idempotent. + */ + private PrincipalSecretsResult bootstrapRealm( + String realmId, RootCredentialsSet rootCredentialsSet, Optional existing) { + LOGGER.info("Bootstrapping realm '{}' ...", realmId); + + // TODO later, update bootstrap to use RealmLifecycleCallbacks and leverage the other + // intermediate realm-states to make this function not racy + + var realmDesc = + existing.orElseGet( + () -> { + var desc = realmManagement.create(realmId); + // Move realm from CREATED to INITIALIZING state + desc = + realmManagement.update( + desc, + RealmDefinition.builder() + .from(desc) + .status(RealmDefinition.RealmStatus.INITIALIZING) + .build()); + return desc; + }); + + if (realmDesc.status() == RealmDefinition.RealmStatus.CREATED) { + realmDesc = + realmManagement.update( + realmDesc, + RealmDefinition.builder() + .from(realmDesc) + .status(RealmDefinition.RealmStatus.INITIALIZING) + .build()); + } + + checkState( + realmDesc.status() == RealmDefinition.RealmStatus.INITIALIZING, + "Unexpected status '%s' for realm '%s'", + realmDesc.status(), + realmId); + + var persistence = buildRealmPersistence(realmId); + persistence.createReferencesSilent(realmReferenceNames()); + var metaStore = newPersistenceMetaStore(persistence); + var metaStoreManager = + new PersistenceMetaStoreManager( + () -> { + throw new IllegalStateException("Cannot purge while bootstrapping"); + }, + rootCredentialsSet, + () -> metaStore, + clock); + + var secretsResult = + bootstrapServiceAndCreatePolarisPrincipalForRealm( + realmId, metaStoreManager, metaStore, rootCredentialsSet); + + realmManagement.update( + realmDesc, + RealmDefinition.builder() + .from(realmDesc) + .status(RealmDefinition.RealmStatus.ACTIVE) + .build()); + + LOGGER.info("Realm '{}' has been successfully bootstrapped.", realmId); + + return secretsResult; + } + + /** + * This method bootstraps service for a given realm: i.e., creates all the required entities in + * the metastore and creates a root service principal. After that, we rotate the root principal + * credentials and print them to stdout + */ + private PrincipalSecretsResult bootstrapServiceAndCreatePolarisPrincipalForRealm( + String realmId, + PersistenceMetaStoreManager metaStoreManager, + PersistenceMetaStore metaStore, + RootCredentialsSet rootCredentialsSet) { + var createPrincipalResult = metaStoreManager.bootstrapPolarisServiceInternal(); + + var rootPrincipal = + createPrincipalResult + .map(result -> (PolarisBaseEntity) result.getPrincipal()) + .orElseGet( + () -> + metaStoreManager + .readEntityByName( + null, + PolarisEntityType.PRINCIPAL, + PolarisEntityConstants.getRootPrincipalName()) + .getEntity()); + + var clientId = + PolarisEntity.of(rootPrincipal) + .getInternalPropertiesAsMap() + .get(PolarisEntityConstants.getClientIdPropertyName()); + checkState(clientId != null, "Root principal has no client-ID"); + var secrets = metaStore.loadPrincipalSecrets(clientId); + + var principalSecrets = createPrincipalResult.map(CreatePrincipalResult::getPrincipalSecrets); + if (principalSecrets.isPresent()) { + LOGGER.debug( + "Root principal created for realm '{}', directly returning credentials for client-ID '{}'", + realmId, + principalSecrets.get().getPrincipalClientId()); + return new PrincipalSecretsResult(principalSecrets.get()); + } + + var providedCredentials = rootCredentialsSet.credentials().get(realmId); + if (providedCredentials != null) { + LOGGER.debug( + "Root principal for realm '{}' already exists, credentials provided externally, returning credentials for client-ID '{}'", + realmId, + providedCredentials.clientId()); + return new PrincipalSecretsResult( + new PolarisPrincipalSecrets( + rootPrincipal.getId(), + providedCredentials.clientId(), + providedCredentials.clientSecret(), + providedCredentials.clientSecret())); + } + + // Have to rotate the secrets to retain the idempotency of this function + var result = + metaStoreManager.rotatePrincipalSecrets( + secrets.getPrincipalId(), false, secrets.getMainSecretHash()); + LOGGER.debug( + "Rotating credentials for root principal for realm '{}', client-ID is '{}'", + realmId, + result.getPrincipalSecrets().getPrincipalClientId()); + return result; + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PersistencePaginationToken.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PersistencePaginationToken.java new file mode 100644 index 0000000000..c6f37b7cbe --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PersistencePaginationToken.java @@ -0,0 +1,70 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.core.persistence.pagination.Token; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; + +/** + * Pagination token for NoSQL that refers to the next {@link + * org.apache.polaris.persistence.nosql.api.index.IndexKey}. The next request will refer to the same + * index, for example, the same catalog state. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutablePersistencePaginationToken.class) +@JsonDeserialize(as = ImmutablePersistencePaginationToken.class) +public interface PersistencePaginationToken extends Token { + String ID = "n"; + + @JsonProperty("c") + ObjRef containerObjRef(); + + @JsonProperty("k") + IndexKey key(); + + @Override + default String getT() { + return ID; + } + + static PersistencePaginationToken paginationToken(ObjRef containerObjRef, IndexKey key) { + return ImmutablePersistencePaginationToken.builder() + .containerObjRef(containerObjRef) + .key(key) + .build(); + } + + final class PersistencePaginationTokenType implements Token.TokenType { + @Override + public String id() { + return ID; + } + + @Override + public Class javaType() { + return PersistencePaginationToken.class; + } + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PolarisPrivilegesProvider.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PolarisPrivilegesProvider.java new file mode 100644 index 0000000000..3d965f427a --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PolarisPrivilegesProvider.java @@ -0,0 +1,42 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.InheritablePrivilege.inheritablePrivilege; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Arrays; +import java.util.stream.Stream; +import org.apache.polaris.core.entity.PolarisPrivilege; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegeDefinition; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesProvider; + +@ApplicationScoped +class PolarisPrivilegesProvider implements PrivilegesProvider { + @Override + public String name() { + return "Polaris privileges provider"; + } + + @Override + public Stream privilegeDefinitions() { + return Arrays.stream(PolarisPrivilege.values()) + .map(p -> PrivilegeDefinition.builder().privilege(inheritablePrivilege(p.name())).build()); + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PrincipalsChangeCommitter.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PrincipalsChangeCommitter.java new file mode 100644 index 0000000000..260f77e136 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PrincipalsChangeCommitter.java @@ -0,0 +1,39 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import jakarta.annotation.Nonnull; +import org.apache.polaris.persistence.nosql.api.commit.CommitException; +import org.apache.polaris.persistence.nosql.api.commit.CommitterState; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.index.UpdatableIndex; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalsObj; + +@FunctionalInterface +interface PrincipalsChangeCommitter { + @Nonnull + ChangeResult change( + @Nonnull CommitterState state, + @Nonnull PrincipalsObj.Builder ref, + @Nonnull UpdatableIndex byName, + @Nonnull UpdatableIndex byId, + @Nonnull UpdatableIndex byClientId) + throws CommitException; +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PrincipalsChangeCommitterWrapper.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PrincipalsChangeCommitterWrapper.java new file mode 100644 index 0000000000..383ac337e7 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/PrincipalsChangeCommitterWrapper.java @@ -0,0 +1,83 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static org.apache.polaris.persistence.nosql.api.index.IndexContainer.newUpdatableIndex; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.INDEX_KEY_SERIALIZER; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; + +import jakarta.annotation.Nonnull; +import java.util.Optional; +import java.util.function.Supplier; +import org.apache.polaris.persistence.nosql.api.commit.CommitException; +import org.apache.polaris.persistence.nosql.api.commit.CommitRetryable; +import org.apache.polaris.persistence.nosql.api.commit.CommitterState; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; +import org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalsObj; + +/** + * Abstracts common {@link ContainerObj#stableIdToName()} and {@link ContainerObj#nameToObjRef()} + * handling for committing operations, for principals. + * + * @param result of the commiting operation + */ +record PrincipalsChangeCommitterWrapper(PrincipalsChangeCommitter changeCommitter) + implements CommitRetryable { + + @SuppressWarnings("DuplicatedCode") + @Nonnull + @Override + public Optional attempt( + @Nonnull CommitterState state, + @Nonnull Supplier> refObjSupplier) + throws CommitException { + var refObj = refObjSupplier.get(); + var byName = + refObj + .map(ContainerObj::nameToObjRef) + .map(c -> c.asUpdatableIndex(state.persistence(), OBJ_REF_SERIALIZER)) + .orElseGet(() -> newUpdatableIndex(state.persistence(), OBJ_REF_SERIALIZER)); + var byId = + refObj + .map(ContainerObj::stableIdToName) + .map(c -> c.asUpdatableIndex(state.persistence(), INDEX_KEY_SERIALIZER)) + .orElseGet(() -> newUpdatableIndex(state.persistence(), INDEX_KEY_SERIALIZER)); + var byClientId = + refObj + .map(PrincipalsObj::byClientId) + .map(c -> c.asUpdatableIndex(state.persistence(), OBJ_REF_SERIALIZER)) + .orElseGet(() -> newUpdatableIndex(state.persistence(), OBJ_REF_SERIALIZER)); + + var ref = PrincipalsObj.builder(); + refObj.ifPresent(ref::from); + + var r = changeCommitter.change(state, ref, byName, byId, byClientId); + + if (r instanceof ChangeResult.CommitChange(RESULT result)) { + ref.byClientId(byClientId.toIndexed("idx-client-id-", state::writeOrReplace)) + .nameToObjRef(byName.toIndexed("idx-name-", state::writeOrReplace)) + .stableIdToName(byId.toIndexed("idx-id-", state::writeOrReplace)); + return state.commitResult(result, ref, refObj); + } + if (r instanceof ChangeResult.NoChange(RESULT result)) { + return state.noCommit(result); + } + throw new IllegalStateException("" + r); + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/SecurableAndGrantee.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/SecurableAndGrantee.java new file mode 100644 index 0000000000..c410e82aca --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/SecurableAndGrantee.java @@ -0,0 +1,59 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import org.apache.polaris.core.entity.PolarisGrantRecord; +import org.apache.polaris.persistence.nosql.coretypes.acl.AclObj; + +/** + * Holds IDs needed during ACL/grants loading, before those are exploded into many {@link + * PolarisGrantRecord}s. + */ +record SecurableAndGrantee( + long securableCatalogId, + long securableId, + int securableTypeCode, + long granteeCatalogId, + long granteeId, + int granteeTypeCode) { + + static SecurableAndGrantee forTriplet(long catalogId, AclObj aclObj, GrantTriplet triplet) { + var reverseOrKey = triplet.reverseOrKey(); + var securableCatalogId = reverseOrKey ? triplet.catalogId() : catalogId; + var securableId = reverseOrKey ? triplet.id() : aclObj.securableId(); + var securableTypeCode = + reverseOrKey ? triplet.typeCode() : aclObj.securableTypeCode().orElseThrow(); + var granteeCatalogId = reverseOrKey ? catalogId : triplet.catalogId(); + var granteeId = reverseOrKey ? aclObj.securableId() : triplet.id(); + var granteeTypeCode = + reverseOrKey ? aclObj.securableTypeCode().orElseThrow() : triplet.typeCode(); + return new SecurableAndGrantee( + securableCatalogId, + securableId, + securableTypeCode, + granteeCatalogId, + granteeId, + granteeTypeCode); + } + + PolarisGrantRecord grantRecordForPrivilege(int privilegeCode) { + return new PolarisGrantRecord( + securableCatalogId, securableId, granteeCatalogId, granteeId, privilegeCode); + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/TypeMapping.java b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/TypeMapping.java new file mode 100644 index 0000000000..8c2c57b7a1 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/java/org/apache/polaris/persistence/nosql/metastore/TypeMapping.java @@ -0,0 +1,707 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; +import static org.apache.polaris.core.entity.CatalogEntity.CATALOG_TYPE_PROPERTY; +import static org.apache.polaris.core.entity.CatalogEntity.DEFAULT_BASE_LOCATION_KEY; +import static org.apache.polaris.core.entity.PolarisEntityConstants.PRINCIPAL_CREDENTIAL_ROTATION_REQUIRED_STATE; +import static org.apache.polaris.core.entity.PolarisEntitySubType.GENERIC_TABLE; +import static org.apache.polaris.core.entity.PolarisEntitySubType.ICEBERG_TABLE; +import static org.apache.polaris.core.entity.PolarisEntitySubType.ICEBERG_VIEW; +import static org.apache.polaris.core.entity.table.IcebergTableLikeEntity.METADATA_LOCATION_KEY; +import static org.apache.polaris.core.policy.PolicyEntity.POLICY_CONTENT_KEY; +import static org.apache.polaris.core.policy.PolicyEntity.POLICY_DESCRIPTION_KEY; +import static org.apache.polaris.core.policy.PolicyEntity.POLICY_TYPE_CODE_KEY; +import static org.apache.polaris.core.policy.PolicyEntity.POLICY_VERSION_KEY; +import static org.apache.polaris.persistence.nosql.coretypes.refs.References.perCatalogReferenceName; + +import jakarta.annotation.Nonnull; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.HashMap; +import java.util.Locale; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.OptionalLong; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; +import org.apache.polaris.core.entity.AsyncTaskType; +import org.apache.polaris.core.entity.EntityNameLookupRecord; +import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.core.entity.PolarisEntityConstants; +import org.apache.polaris.core.entity.PolarisEntitySubType; +import org.apache.polaris.core.entity.PolarisEntityType; +import org.apache.polaris.core.entity.PolarisPrincipalSecrets; +import org.apache.polaris.core.entity.PolarisTaskConstants; +import org.apache.polaris.core.entity.table.GenericTableEntity; +import org.apache.polaris.core.entity.table.federated.FederatedEntities; +import org.apache.polaris.core.policy.PolicyType; +import org.apache.polaris.core.storage.PolarisStorageConfigurationInfo; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjTypes; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; +import org.apache.polaris.persistence.nosql.coretypes.ObjBase; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogObj; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogObjBase; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogRoleObj; +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.CatalogStatus; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogType; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogsObj; +import org.apache.polaris.persistence.nosql.coretypes.catalog.FileObj; +import org.apache.polaris.persistence.nosql.coretypes.content.GenericTableObj; +import org.apache.polaris.persistence.nosql.coretypes.content.IcebergTableObj; +import org.apache.polaris.persistence.nosql.coretypes.content.IcebergViewObj; +import org.apache.polaris.persistence.nosql.coretypes.content.LocalNamespaceObj; +import org.apache.polaris.persistence.nosql.coretypes.content.NamespaceObj; +import org.apache.polaris.persistence.nosql.coretypes.content.PolicyObj; +import org.apache.polaris.persistence.nosql.coretypes.content.RemoteNamespaceObj; +import org.apache.polaris.persistence.nosql.coretypes.content.TableLikeObj; +import org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalObj; +import org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalRoleObj; +import org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalRolesObj; +import org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalsObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.ImmediateTaskObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.ImmediateTasksObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.RootObj; + +class TypeMapping { + static ByteBuffer serializeStringCompressed(String entityAsJson) { + try (var byteArrayOutputStream = new ByteArrayOutputStream()) { + try (var gzip = new GZIPOutputStream(byteArrayOutputStream); + var out = new DataOutputStream(gzip)) { + out.writeUTF(entityAsJson); + } + return ByteBuffer.wrap(byteArrayOutputStream.toByteArray()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + static String deserializeStringCompressed(ByteBuffer bytes) { + var byteArray = new byte[bytes.remaining()]; + bytes.duplicate().get(byteArray); + try (var in = new DataInputStream(new GZIPInputStream(new ByteArrayInputStream(byteArray)))) { + return in.readUTF(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Nonnull + static > B mapToObj( + @Nonnull PolarisBaseEntity entity, Optional principalSecrets) { + var type = entity.getType(); + var subType = entity.getSubType(); + var properties = new HashMap<>(entity.getPropertiesAsMap()); + var internalProperties = new HashMap<>(entity.getInternalPropertiesAsMap()); + var baseBuilder = (B) null; + baseBuilder = + cast( + switch (type) { + case CATALOG -> { + checkArgument( + subType == PolarisEntitySubType.NULL_SUBTYPE, "invalid subtype for %s", type); + var defaultBaseLocation = properties.remove(DEFAULT_BASE_LOCATION_KEY); + var catalogTypeString = internalProperties.remove(CATALOG_TYPE_PROPERTY); + if (catalogTypeString == null) { + catalogTypeString = CatalogType.INTERNAL.name(); + } + var catalogType = + switch (catalogTypeString.toUpperCase(Locale.ROOT)) { + case "INTERNAL" -> CatalogType.INTERNAL; + case "EXTERNAL" -> CatalogType.EXTERNAL; + default -> + throw new IllegalArgumentException( + "Invalid catalog type " + catalogTypeString); + }; + yield CatalogObj.builder() + .catalogType(catalogType) + .defaultBaseLocation(java.util.Optional.ofNullable(defaultBaseLocation)) + .status(CatalogStatus.ACTIVE); + } + case CATALOG_ROLE -> { + checkArgument( + subType == PolarisEntitySubType.NULL_SUBTYPE, "invalid subtype for %s", type); + yield CatalogRoleObj.builder(); + } + case POLICY -> { + checkArgument( + subType == PolarisEntitySubType.NULL_SUBTYPE, "invalid subtype for %s", type); + + var policyTypeCode = properties.remove(POLICY_TYPE_CODE_KEY); + var description = properties.remove(POLICY_DESCRIPTION_KEY); + var content = properties.remove(POLICY_CONTENT_KEY); + var version = properties.remove(POLICY_VERSION_KEY); + + yield PolicyObj.builder() + .policyType(PolicyType.fromCode(Integer.parseInt(policyTypeCode))) + .description(java.util.Optional.ofNullable(description)) + .content(java.util.Optional.ofNullable(content)) + .policyVersion( + version != null + ? OptionalInt.of(Integer.parseInt(version)) + : OptionalInt.empty()); + } + case FILE -> { + checkArgument( + subType == PolarisEntitySubType.NULL_SUBTYPE, "invalid subtype for %s", type); + yield FileObj.builder(); + } + case NAMESPACE -> { + checkArgument( + subType == PolarisEntitySubType.NULL_SUBTYPE, "invalid subtype for %s", type); + // TODO RemoteNamespaceObj ? + yield LocalNamespaceObj.builder(); + } + case ROOT -> { + checkArgument( + subType == PolarisEntitySubType.NULL_SUBTYPE, "invalid subtype for %s", type); + yield RootObj.builder(); + } + case PRINCIPAL -> { + checkArgument( + subType == PolarisEntitySubType.NULL_SUBTYPE, "invalid subtype for %s", type); + var credentialRotationRequired = + internalProperties.remove(PRINCIPAL_CREDENTIAL_ROTATION_REQUIRED_STATE); + internalProperties.remove(PolarisEntityConstants.getClientIdPropertyName()); + + var principalObjBuilder = + PrincipalObj.builder() + .credentialRotationRequired(credentialRotationRequired != null); + principalSecrets.ifPresent( + secrets -> + principalObjBuilder + .clientId(secrets.getPrincipalClientId()) + .mainSecretHash(secrets.getMainSecretHash()) + .secondarySecretHash(secrets.getSecondarySecretHash()) + .secretSalt(secrets.getSecretSalt())); + + yield principalObjBuilder; + } + case PRINCIPAL_ROLE -> { + checkArgument( + subType == PolarisEntitySubType.NULL_SUBTYPE, "invalid subtype for %s", type); + var federated = + Boolean.parseBoolean( + internalProperties.remove(FederatedEntities.FEDERATED_ENTITY)); + yield PrincipalRoleObj.builder().federated(federated); + } + case TASK -> { + checkArgument( + subType == PolarisEntitySubType.NULL_SUBTYPE, "invalid subtype for %s", type); + var b = ImmediateTaskObj.builder(); + var taskTypeCode = properties.remove(PolarisTaskConstants.TASK_TYPE); + var lastAttemptExecutorId = + properties.remove(PolarisTaskConstants.LAST_ATTEMPT_EXECUTOR_ID); + if (lastAttemptExecutorId != null) { + b.lastAttemptExecutorId(lastAttemptExecutorId); + } + var lastAttemptStartTime = + properties.remove(PolarisTaskConstants.LAST_ATTEMPT_START_TIME); + if (lastAttemptStartTime != null) { + b.lastAttemptStartTime( + Instant.ofEpochMilli(Long.parseLong(lastAttemptStartTime))); + } + var attemptCount = properties.remove(PolarisTaskConstants.ATTEMPT_COUNT); + if (attemptCount != null) { + b.attemptCount(Integer.parseInt(attemptCount)); + } + yield b.taskType( + Optional.ofNullable( + taskTypeCode != null + ? AsyncTaskType.fromTypeCode(Integer.parseInt(taskTypeCode)) + : null)) + .serializedEntity( + Optional.ofNullable(properties.remove("data")) + .map(TypeMapping::serializeStringCompressed)); + } + case TABLE_LIKE -> { + var tlBuilder = (TableLikeObj.Builder) null; + tlBuilder = + switch (subType) { + case ICEBERG_TABLE -> IcebergTableObj.builder(); + case ICEBERG_VIEW -> IcebergViewObj.builder(); + case GENERIC_TABLE -> + GenericTableObj.builder() + .format( + Optional.ofNullable( + internalProperties.remove(GenericTableEntity.FORMAT_KEY))) + .doc( + Optional.ofNullable( + internalProperties.remove(GenericTableEntity.DOC_KEY))); + default -> + throw new IllegalArgumentException("Unknown or invalid subtype " + type); + }; + + Optional.ofNullable(internalProperties.remove(METADATA_LOCATION_KEY)) + .ifPresent(tlBuilder::metadataLocation); + + yield tlBuilder; + } + default -> throw new IllegalArgumentException("Unknown type " + type); + }); + + if (baseBuilder instanceof CatalogObjBase.Builder catalogObjBaseBuilder) { + catalogObjBaseBuilder.storageConfigurationInfo( + Optional.ofNullable( + internalProperties.remove( + PolarisEntityConstants.getStorageConfigInfoPropertyName())) + .map(PolarisStorageConfigurationInfo::deserialize)); + catalogObjBaseBuilder.storageIntegrationIdentifier( + Optional.ofNullable( + internalProperties.remove( + PolarisEntityConstants.getStorageIntegrationIdentifierPropertyName()))); + } + + baseBuilder + .name(entity.getName()) + .entityVersion(entity.getEntityVersion()) + .stableId(entity.getId()) + .parentStableId(entity.getParentId()) + .createTimestamp(Instant.ofEpochMilli(entity.getCreateTimestamp())) + .updateTimestamp(Instant.ofEpochMilli(entity.getLastUpdateTimestamp())); + + for (var e : properties.entrySet()) { + if (e.getValue() != null) { + baseBuilder.putProperty(e.getKey(), e.getValue()); + } + } + for (var e : internalProperties.entrySet()) { + if (e.getValue() != null) { + baseBuilder.putInternalProperty(e.getKey(), e.getValue()); + } + } + + return baseBuilder; + } + + static boolean isCatalogContent(int entityTypeCode) { + return entityTypeCode == PolarisEntityType.NAMESPACE.getCode() + || entityTypeCode == PolarisEntityType.TABLE_LIKE.getCode() + || entityTypeCode == PolarisEntityType.POLICY.getCode(); + } + + static boolean isCatalogContent(PolarisEntityType entityType) { + return switch (entityType) { + case NAMESPACE, TABLE_LIKE, POLICY -> true; + default -> false; + }; + } + + static Class objTypeForPolarisTypeForFiltering( + PolarisEntityType entityType, PolarisEntitySubType subType) { + return switch (entityType) { + case PRINCIPAL -> PrincipalObj.class; + case TASK -> ImmediateTaskObj.class; + case TABLE_LIKE -> + switch (subType) { + case ICEBERG_TABLE -> IcebergTableObj.class; + case ICEBERG_VIEW -> IcebergViewObj.class; + case GENERIC_TABLE -> GenericTableObj.class; + case ANY_SUBTYPE -> TableLikeObj.class; + default -> throw new IllegalArgumentException("Illegal subtype " + subType); + }; + case NAMESPACE -> NamespaceObj.class; + case CATALOG -> CatalogObj.class; + case CATALOG_ROLE -> CatalogRoleObj.class; + case POLICY -> PolicyObj.class; + case PRINCIPAL_ROLE -> PrincipalRoleObj.class; + case ROOT -> RootObj.class; + case FILE -> FileObj.class; + default -> throw new IllegalArgumentException("Illegal entity type " + entityType); + }; + } + + static ObjType objTypeForPolarisType(PolarisEntityType entityType, PolarisEntitySubType subType) { + return switch (entityType) { + case PRINCIPAL -> PrincipalObj.TYPE; + case TASK -> ImmediateTaskObj.TYPE; + case TABLE_LIKE -> + switch (subType) { + case ICEBERG_TABLE -> IcebergTableObj.TYPE; + case ICEBERG_VIEW -> IcebergViewObj.TYPE; + case GENERIC_TABLE -> GenericTableObj.TYPE; + default -> throw new IllegalArgumentException("Illegal subtype " + subType); + }; + case NAMESPACE -> LocalNamespaceObj.TYPE; + case CATALOG -> CatalogObj.TYPE; + case CATALOG_ROLE -> CatalogRoleObj.TYPE; + case POLICY -> PolicyObj.TYPE; + case PRINCIPAL_ROLE -> PrincipalRoleObj.TYPE; + case ROOT -> RootObj.TYPE; + case FILE -> FileObj.TYPE; + default -> throw new IllegalArgumentException("Illegal entity type " + entityType); + }; + } + + static Class containerTypeForEntityType( + int entityTypeCode, boolean forCatalog) { + return containerTypeForEntityType(typeFromCode(entityTypeCode), forCatalog); + } + + static Class containerTypeForEntityType( + PolarisEntityType entityType, boolean forCatalog) { + return switch (entityType) { + case CATALOG -> forCatalog ? CatalogStateObj.class : CatalogsObj.class; + case PRINCIPAL -> PrincipalsObj.class; + case PRINCIPAL_ROLE -> PrincipalRolesObj.class; + case TASK -> ImmediateTasksObj.class; + + // per catalog + case CATALOG_ROLE -> CatalogRolesObj.class; + case NAMESPACE, TABLE_LIKE, POLICY -> CatalogStateObj.class; + + default -> throw new IllegalArgumentException("Unsupported entity type: " + entityType); + }; + } + + static ContainerObj.Builder> + newContainerBuilderForEntityType(PolarisEntityType entityType) { + return switch (entityType) { + case CATALOG -> CatalogsObj.builder(); + case PRINCIPAL -> PrincipalsObj.builder(); + case PRINCIPAL_ROLE -> PrincipalRolesObj.builder(); + case TASK -> ImmediateTasksObj.builder(); + + // per catalog + case CATALOG_ROLE -> CatalogRolesObj.builder(); + case NAMESPACE, TABLE_LIKE, POLICY -> CatalogStateObj.builder(); + + default -> throw new IllegalArgumentException("Unsupported entity type: " + entityType); + }; + } + + static PolarisEntityType typeFromCode(int entityTypeCode) { + return requireNonNull(PolarisEntityType.fromCode(entityTypeCode), "Invalid type code"); + } + + static Optional filterIsEntityType( + @Nonnull C objBase, int entityTypeCode) { + return filterIsEntityType(objBase, typeFromCode(entityTypeCode)); + } + + static Optional filterIsEntityType( + @Nonnull C objBase, PolarisEntityType entityType) { + return objTypeForPolarisTypeForFiltering(entityType, PolarisEntitySubType.ANY_SUBTYPE) + .isInstance(objBase) + ? Optional.of(objBase) + : Optional.empty(); + } + + @Nonnull + static PolarisBaseEntity mapToEntity(@Nonnull ObjBase objBase, long catalogId) { + var properties = new HashMap<>(objBase.properties()); + var internalProperties = new HashMap<>(objBase.internalProperties()); + var type = PolarisEntityType.NULL_TYPE; + var subType = PolarisEntitySubType.NULL_SUBTYPE; + var parentId = objBase.parentStableId(); + + switch (objBase) { + case TableLikeObj o -> { + catalogMandatory(catalogId); + type = PolarisEntityType.TABLE_LIKE; + o.metadataLocation().ifPresent(v -> internalProperties.put(METADATA_LOCATION_KEY, v)); + if (objBase instanceof IcebergTableObj) { + subType = ICEBERG_TABLE; + } + if (objBase instanceof IcebergViewObj) { + subType = ICEBERG_VIEW; + } + if (objBase instanceof GenericTableObj genericTableObj) { + subType = GENERIC_TABLE; + genericTableObj + .format() + .ifPresent(v -> internalProperties.put(GenericTableEntity.FORMAT_KEY, v)); + genericTableObj + .doc() + .ifPresent(v -> internalProperties.put(GenericTableEntity.DOC_KEY, v)); + } + } + case LocalNamespaceObj ignored -> { + catalogMandatory(catalogId); + type = PolarisEntityType.NAMESPACE; + } + case RemoteNamespaceObj ignored -> { + catalogMandatory(catalogId); + // TODO RemoteNamespaceObj ? + } + case CatalogObj o -> { + catalogId = realmMandatory(catalogId); + type = PolarisEntityType.CATALOG; + internalProperties.put(CATALOG_TYPE_PROPERTY, o.catalogType().name()); + o.defaultBaseLocation().ifPresent(v -> properties.put(DEFAULT_BASE_LOCATION_KEY, v)); + } + case CatalogRoleObj ignored -> { + catalogMandatory(catalogId); + type = PolarisEntityType.CATALOG_ROLE; + } + case PolicyObj o -> { + catalogMandatory(catalogId); + properties.put(POLICY_TYPE_CODE_KEY, Integer.toString(o.policyType().getCode())); + o.description().ifPresent(v -> properties.put(POLICY_DESCRIPTION_KEY, v)); + o.content().ifPresent(v -> properties.put(POLICY_CONTENT_KEY, v)); + o.policyVersion().ifPresent(v -> properties.put(POLICY_VERSION_KEY, Integer.toString(v))); + type = PolarisEntityType.POLICY; + } + case RootObj ignored -> { + catalogId = realmMandatory(catalogId); + type = PolarisEntityType.ROOT; + } + case ImmediateTaskObj o -> { + catalogId = realmMandatory(catalogId); + o.serializedEntity() + .map(TypeMapping::deserializeStringCompressed) + .ifPresent(s -> properties.put("data", s)); + o.taskType() + .ifPresent( + v -> + properties.put(PolarisTaskConstants.TASK_TYPE, Integer.toString(v.typeCode()))); + o.lastAttemptExecutorId() + .ifPresent(v -> properties.put(PolarisTaskConstants.LAST_ATTEMPT_EXECUTOR_ID, v)); + o.lastAttemptStartTime() + .ifPresent( + v -> + properties.put( + PolarisTaskConstants.LAST_ATTEMPT_START_TIME, + Long.toString(v.toEpochMilli()))); + o.attemptCount() + .ifPresent( + v -> properties.put(PolarisTaskConstants.ATTEMPT_COUNT, Integer.toString(v))); + type = PolarisEntityType.TASK; + } + case PrincipalObj o -> { + catalogId = realmMandatory(catalogId); + type = PolarisEntityType.PRINCIPAL; + if (o.credentialRotationRequired()) { + internalProperties.put(PRINCIPAL_CREDENTIAL_ROTATION_REQUIRED_STATE, "true"); + } + o.clientId() + .ifPresent( + v -> internalProperties.put(PolarisEntityConstants.getClientIdPropertyName(), v)); + } + case PrincipalRoleObj o -> { + type = PolarisEntityType.PRINCIPAL_ROLE; + if (o.federated()) { + internalProperties.put(FederatedEntities.FEDERATED_ENTITY, "true"); + } + } + default -> + throw new IllegalStateException( + "Cannot map " + objBase.type().targetClass().getSimpleName() + " to a PolarisEntity"); + } + + if (objBase instanceof CatalogObjBase catalogObjBase) { + catalogObjBase + .storageConfigurationInfo() + .ifPresent( + polarisStorageConfigInfo -> + internalProperties.put( + PolarisEntityConstants.getStorageConfigInfoPropertyName(), + polarisStorageConfigInfo.serialize())); + catalogObjBase + .storageIntegrationIdentifier() + .ifPresent( + ident -> + internalProperties.put( + PolarisEntityConstants.getStorageIntegrationIdentifierPropertyName(), ident)); + } + + var id = objBase.stableId(); + var name = objBase.name(); + return new PolarisBaseEntity.Builder() + .catalogId(catalogId) + .id(id) + .typeCode(type.getCode()) + .subTypeCode(subType.getCode()) + .parentId(parentId) + .name(name) + .propertiesAsMap(properties) + .internalPropertiesAsMap(internalProperties) + .createTimestamp(objBase.createTimestamp().toEpochMilli()) + .lastUpdateTimestamp(objBase.updateTimestamp().toEpochMilli()) + .entityVersion(objBase.entityVersion()) + .build(); + } + + @Nonnull + static EntityNameLookupRecord mapToEntityNameLookupRecord( + @Nonnull ObjBase objBase, long catalogStableId) { + var type = PolarisEntityType.NULL_TYPE; + var subType = PolarisEntitySubType.NULL_SUBTYPE; + + switch (objBase) { + case TableLikeObj ignored -> { + catalogMandatory(catalogStableId); + type = PolarisEntityType.TABLE_LIKE; + if (objBase instanceof IcebergTableObj) { + subType = ICEBERG_TABLE; + } + if (objBase instanceof IcebergViewObj) { + subType = ICEBERG_VIEW; + } + if (objBase instanceof GenericTableObj) { + subType = GENERIC_TABLE; + } + } + case LocalNamespaceObj ignored -> { + catalogMandatory(catalogStableId); + type = PolarisEntityType.NAMESPACE; + } + case RemoteNamespaceObj ignored -> { + // TODO RemoteNamespaceObj ? + } + case PolicyObj ignored -> { + catalogMandatory(catalogStableId); + type = PolarisEntityType.POLICY; + } + case CatalogObj ignored -> { + catalogStableId = realmMandatory(catalogStableId); + type = PolarisEntityType.CATALOG; + } + case CatalogRoleObj ignored -> { + catalogMandatory(catalogStableId); + type = PolarisEntityType.CATALOG_ROLE; + } + case RootObj ignored -> { + catalogStableId = realmMandatory(catalogStableId); + type = PolarisEntityType.ROOT; + } + case ImmediateTaskObj ignored -> { + catalogStableId = realmMandatory(catalogStableId); + type = PolarisEntityType.TASK; + } + case PrincipalObj ignored -> { + catalogStableId = realmMandatory(catalogStableId); + type = PolarisEntityType.PRINCIPAL; + } + case PrincipalRoleObj ignored -> { + catalogStableId = realmMandatory(catalogStableId); + type = PolarisEntityType.PRINCIPAL_ROLE; + } + default -> + throw new IllegalStateException( + "Cannot map " + + objBase.type().targetClass().getSimpleName() + + " to a entity type/sub-type"); + } + + // TODO + var parentId = 0L; + + return new EntityNameLookupRecord( + catalogStableId, + objBase.stableId(), + parentId, + objBase.name(), + type.getCode(), + subType.getCode()); + } + + @SuppressWarnings("unchecked") + private static R cast(Object r) { + return (R) r; + } + + static String referenceName(PolarisEntityType entityType, Optional catalog) { + var catalogStableId = catalog.map(ObjBase::stableId).orElse(0L); + return referenceName(entityType, catalogStableId); + } + + static String referenceName(PolarisEntityType entityType, OptionalLong catalogId) { + return referenceName(entityType, catalogId.orElse(0L)); + } + + static String referenceName(PolarisEntityType entityType, long catalogStableId) { + return switch (entityType) { + case CATALOG -> CatalogsObj.CATALOGS_REF_NAME; + case PRINCIPAL -> PrincipalsObj.PRINCIPALS_REF_NAME; + case PRINCIPAL_ROLE -> PrincipalRolesObj.PRINCIPAL_ROLES_REF_NAME; + case TASK -> ImmediateTasksObj.IMMEDIATE_TASKS_REF_NAME; + case ROOT -> RootObj.ROOT_REF_NAME; + + // per catalog + case CATALOG_ROLE -> + perCatalogReferenceName(CatalogRolesObj.CATALOG_ROLES_REF_NAME_PATTERN, catalogStableId); + case NAMESPACE, TABLE_LIKE, POLICY -> + perCatalogReferenceName(CatalogStateObj.CATALOG_STATE_REF_NAME_PATTERN, catalogStableId); + + default -> throw new IllegalArgumentException("Unsupported entity type: " + entityType); + }; + } + + static void catalogMandatory(long catalogStableId) { + checkArgument(catalogStableId != 0L, "Mandatory catalog not present"); + } + + static long realmMandatory(long catalogStableId) { + // TODO BasePolarisMetaStoreManagerTest sadly gives us non-0 catalog-IDs for non-catalog + // entities, so cannot do the following assertion, but instead blindly assume 0L. + // checkArgument(catalogStableId == 0L, "Catalog present"); + return 0L; + } + + static String entitySubTypeCodeFromObjType(ObjRef objRef) { + if (objRef != null) { + var objType = ObjTypes.objTypeById(objRef.type()); + if (objType.equals(IcebergTableObj.TYPE)) { + return Integer.toString(ICEBERG_TABLE.getCode()); + } + if (objType.equals(IcebergViewObj.TYPE)) { + return Integer.toString(ICEBERG_VIEW.getCode()); + } + if (objType.equals(GenericTableObj.TYPE)) { + return Integer.toString(GENERIC_TABLE.getCode()); + } + } + return null; + } + + static Optional maybeObjToPolarisPrincipalSecrets(ObjBase obj) { + if (obj instanceof PrincipalObj principalObj && principalObj.clientId().isPresent()) { + return Optional.of(principalObjToPolarisPrincipalSecrets(principalObj)); + } + return Optional.empty(); + } + + static PolarisPrincipalSecrets principalObjToPolarisPrincipalSecrets(PrincipalObj principalObj) { + return principalObjToPolarisPrincipalSecrets(principalObj, null); + } + + static PolarisPrincipalSecrets principalObjToPolarisPrincipalSecrets( + PrincipalObj principalObj, PolarisPrincipalSecrets newPrincipalSecrets) { + return new PolarisPrincipalSecrets( + principalObj.stableId(), + principalObj.clientId().orElse(null), + newPrincipalSecrets != null ? newPrincipalSecrets.getMainSecret() : null, + newPrincipalSecrets != null ? newPrincipalSecrets.getSecondarySecret() : null, + principalObj.secretSalt().orElse(null), + principalObj.mainSecretHash().orElse(null), + principalObj.secondarySecretHash().orElse(null)); + } +} diff --git a/persistence/nosql/persistence/metastore/src/main/resources/META-INF/beans.xml b/persistence/nosql/persistence/metastore/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/persistence/metastore/src/main/resources/META-INF/services/org.apache.polaris.core.persistence.pagination.Token$TokenType b/persistence/nosql/persistence/metastore/src/main/resources/META-INF/services/org.apache.polaris.core.persistence.pagination.Token$TokenType new file mode 100644 index 0000000000..f41b859cf3 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/main/resources/META-INF/services/org.apache.polaris.core.persistence.pagination.Token$TokenType @@ -0,0 +1,20 @@ +# +# 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. +# + +org.apache.polaris.persistence.nosql.metastore.PersistencePaginationToken$PersistencePaginationTokenType diff --git a/persistence/nosql/persistence/metastore/src/test/java/org/apache/polaris/persistence/nosql/metastore/TestIdentifier.java b/persistence/nosql/persistence/metastore/src/test/java/org/apache/polaris/persistence/nosql/metastore/TestIdentifier.java new file mode 100644 index 0000000000..5036a6bde1 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/test/java/org/apache/polaris/persistence/nosql/metastore/TestIdentifier.java @@ -0,0 +1,62 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.util.List; +import java.util.stream.Stream; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestIdentifier { + @InjectSoftAssertions SoftAssertions soft; + + @ParameterizedTest + @MethodSource + public void storageLocation(String input, List elements) { + var identifier = Identifier.identifierFromLocationString(input); + soft.assertThat(identifier.elements()).containsExactlyElementsOf(elements); + var inKey = identifier.toIndexKey(); + var identifierFromKey = Identifier.indexKeyToIdentifier(inKey); + soft.assertThat(identifierFromKey).isEqualTo(identifierFromKey); + } + + static Stream storageLocation() { + return Stream.of( + arguments("", List.of()), + arguments("foo", List.of("foo")), + arguments("//foo", List.of("foo")), + arguments("//foo/\\/bar", List.of("foo", "bar")), + arguments("\\foo/\\/bar", List.of("foo", "bar")), + arguments("\\/foo/\\/bar", List.of("foo", "bar")), + arguments("\\/\\foo/\\/bar", List.of("foo", "bar")), + arguments("foo/\\/bar", List.of("foo", "bar")), + arguments("foo/\\/bar/", List.of("foo", "bar")), + arguments("foo/\\/bar\\", List.of("foo", "bar")), + arguments("foo/\\/bar/\\/", List.of("foo", "bar"))); + } +} diff --git a/persistence/nosql/persistence/metastore/src/test/java/org/apache/polaris/persistence/nosql/metastore/TestPersistenceMetaStoreManager.java b/persistence/nosql/persistence/metastore/src/test/java/org/apache/polaris/persistence/nosql/metastore/TestPersistenceMetaStoreManager.java new file mode 100644 index 0000000000..ebad604cbc --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/test/java/org/apache/polaris/persistence/nosql/metastore/TestPersistenceMetaStoreManager.java @@ -0,0 +1,259 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static org.apache.polaris.core.entity.PolarisEntityConstants.ENTITY_BASE_LOCATION; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.InstanceOfAssertFactories.BOOLEAN; + +import io.smallrye.common.annotation.Identifier; +import jakarta.inject.Inject; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.apache.iceberg.catalog.Namespace; +import org.apache.polaris.core.PolarisCallContext; +import org.apache.polaris.core.config.PolarisConfigurationStore; +import org.apache.polaris.core.context.RealmContext; +import org.apache.polaris.core.entity.NamespaceEntity; +import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.core.entity.PolarisEntityConstants; +import org.apache.polaris.core.entity.PolarisEntityCore; +import org.apache.polaris.core.entity.PolarisEntitySubType; +import org.apache.polaris.core.entity.PolarisEntityType; +import org.apache.polaris.core.persistence.BasePolarisMetaStoreManagerTest; +import org.apache.polaris.core.persistence.MetaStoreManagerFactory; +import org.apache.polaris.core.persistence.PolarisMetaStoreManager; +import org.apache.polaris.core.persistence.PolarisTestMetaStoreManager; +import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet; +import org.apache.polaris.core.persistence.dao.entity.CreateCatalogResult; +import org.apache.polaris.core.persistence.dao.entity.EntityResult; +import org.apache.polaris.ids.api.MonotonicClock; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.jboss.weld.junit5.EnableWeld; +import org.jboss.weld.junit5.WeldInitiator; +import org.jboss.weld.junit5.WeldSetup; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@SuppressWarnings("CdiInjectionPointsInspection") +@EnableWeld +@ExtendWith(SoftAssertionsExtension.class) +public class TestPersistenceMetaStoreManager extends BasePolarisMetaStoreManagerTest { + @WeldSetup WeldInitiator weld = WeldInitiator.performDefaultDiscovery(); + + @InjectSoftAssertions SoftAssertions soft; + + @Inject + @Identifier("nosql") + MetaStoreManagerFactory metaStoreManagerFactory; + + @Inject PolarisConfigurationStore configurationStore; + @Inject MonotonicClock monotonicClock; + + String realmId; + RealmContext realmContext; + + PolarisMetaStoreManager metaStore; + PolarisCallContext callContext; + + @Override + protected PolarisTestMetaStoreManager createPolarisTestMetaStoreManager() { + realmId = UUID.randomUUID().toString(); + realmContext = () -> realmId; + + var startTime = monotonicClock.currentTimeMillis(); + + metaStoreManagerFactory.bootstrapRealms(List.of(realmId), RootCredentialsSet.fromEnvironment()); + + var manager = metaStoreManagerFactory.getOrCreateMetaStoreManager(realmContext); + var session = metaStoreManagerFactory.getOrCreateSession(realmContext); + + var callCtx = new PolarisCallContext(realmContext, session, configurationStore); + + return new PolarisTestMetaStoreManager(manager, callCtx, startTime, false); + } + + @Override + @Disabled("No entity cache, no need to test it") + protected void testEntityCache() {} + + @Override + @Disabled( + "Nothing in the code base calls 'loadTasks', the contract of that function is not what the test exercises") + protected void testLoadTasksInParallel() {} + + @Override + @Disabled( + "Nothing in the code base calls 'loadTasks', the contract of that function is not what the test exercises") + protected void testLoadTasks() {} + + @Override + protected void testLoadResolvedEntitiesById() { + assertThatCode(super::testLoadResolvedEntitiesById) + .isInstanceOf(UnsupportedOperationException.class); + } + + @BeforeEach + void setup() { + this.metaStore = polarisTestMetaStoreManager.polarisMetaStoreManager(); + this.callContext = polarisTestMetaStoreManager.polarisCallContext(); + } + + @Test + public void overlappingLocations() { + PolarisBaseEntity catalog = + new PolarisBaseEntity( + PolarisEntityConstants.getNullId(), + metaStore.generateNewEntityId(callContext).getId(), + PolarisEntityType.CATALOG, + PolarisEntitySubType.NULL_SUBTYPE, + PolarisEntityConstants.getRootEntityId(), + "overlappingLocations"); + CreateCatalogResult catalogCreated = metaStore.createCatalog(callContext, catalog, List.of()); + assertThat(catalogCreated).isNotNull(); + catalog = catalogCreated.getCatalog(); + + var nsFoo = + createEntity( + List.of(catalog), + PolarisEntityType.NAMESPACE, + PolarisEntitySubType.NULL_SUBTYPE, + "ns2", + Map.of(ENTITY_BASE_LOCATION, "s3://bucket/foo/")); + assertThat(nsFoo).extracting(EntityResult::isSuccess, BOOLEAN).isTrue(); + var nsBar = + createEntity( + List.of(catalog), + PolarisEntityType.NAMESPACE, + PolarisEntitySubType.NULL_SUBTYPE, + "ns3", + Map.of(ENTITY_BASE_LOCATION, "s3://bucket/bar/")); + assertThat(nsBar).extracting(EntityResult::isSuccess, BOOLEAN).isTrue(); + var nsFoobar = + createEntity( + List.of(catalog), + PolarisEntityType.NAMESPACE, + PolarisEntitySubType.NULL_SUBTYPE, + "foobar", + Map.of(ENTITY_BASE_LOCATION, "s3://bucket/foo/bar/")); + assertThat(nsFoobar).extracting(EntityResult::isSuccess, BOOLEAN).isTrue(); + var nsFoobar2 = + createEntity( + List.of(catalog), + PolarisEntityType.NAMESPACE, + PolarisEntitySubType.NULL_SUBTYPE, + "foobar2", + // Same location again + Map.of(ENTITY_BASE_LOCATION, "s3://bucket/foo/bar/")); + assertThat(nsFoobar2).extracting(EntityResult::isSuccess, BOOLEAN).isTrue(); + + soft.assertThat( + metaStore.hasOverlappingSiblings( + callContext, + new NamespaceEntity.Builder(Namespace.of("x")) + .setCatalogId(catalog.getId()) + .setBaseLocation("s3://bucket/foo/") + .build())) + .isPresent() + .contains(Optional.of("s3://bucket/foo/")); + + for (var check : + List.of( + "s3://bucket/foo/bar", + "s3://bucket/foo/bar/", + "s3a://bucket/foo/bar/", + "gs://bucket/foo/bar/")) { + soft.assertThat( + metaStore.hasOverlappingSiblings( + callContext, + new NamespaceEntity.Builder(Namespace.of("x")) + .setCatalogId(catalog.getId()) + .setBaseLocation(check) + .build())) + .isPresent() + .contains(Optional.of("s3://bucket/foo/bar/")); + } + + soft.assertThat( + metaStore.hasOverlappingSiblings( + callContext, + new NamespaceEntity.Builder(Namespace.of("x")) + .setCatalogId(catalog.getId()) + .setBaseLocation("s3://other/data/stuff/") + .build())) + .isPresent() + .contains(Optional.empty()); + + // Drop one of the entities with the duplicate base location + metaStore.dropEntityIfExists(callContext, List.of(catalog), nsFoobar.getEntity(), null, false); + // Must still report an overlap + soft.assertThat( + metaStore.hasOverlappingSiblings( + callContext, + new NamespaceEntity.Builder(Namespace.of("x")) + .setCatalogId(catalog.getId()) + .setBaseLocation("s3://bucket/foo/bar") + .build())) + .isPresent() + .contains(Optional.of("s3://bucket/foo/bar/")); + + // Drop one of the entities with the duplicate base location + metaStore.dropEntityIfExists(callContext, List.of(catalog), nsFoobar2.getEntity(), null, false); + // No more overlaps + soft.assertThat( + metaStore.hasOverlappingSiblings( + callContext, + new NamespaceEntity.Builder(Namespace.of("x")) + .setCatalogId(catalog.getId()) + .setBaseLocation("s3://bucket/foo/bar") + .build())) + .isPresent() + .contains(Optional.empty()); + } + + @SuppressWarnings("SameParameterValue") + EntityResult createEntity( + List catalogPath, + PolarisEntityType entityType, + PolarisEntitySubType entitySubType, + String name, + Map properties) { + var entityId = metaStore.generateNewEntityId(callContext).getId(); + PolarisBaseEntity newEntity = + new PolarisBaseEntity.Builder() + .catalogId(catalogPath.getFirst().getId()) + .id(entityId) + .typeCode(entityType.getCode()) + .subTypeCode(entitySubType.getCode()) + .parentId(catalogPath.getLast().getId()) + .name(name) + .propertiesAsMap(properties) + .build(); + @SuppressWarnings({"unchecked", "rawtypes"}) + var path = (List) (List) catalogPath; + return metaStore.createEntityIfNotExists(callContext, path, newEntity); + } +} diff --git a/persistence/nosql/persistence/metastore/src/test/java/org/apache/polaris/persistence/nosql/metastore/TestPersistenceResolver.java b/persistence/nosql/persistence/metastore/src/test/java/org/apache/polaris/persistence/nosql/metastore/TestPersistenceResolver.java new file mode 100644 index 0000000000..1592e503eb --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/test/java/org/apache/polaris/persistence/nosql/metastore/TestPersistenceResolver.java @@ -0,0 +1,105 @@ +/* + * 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.polaris.persistence.nosql.metastore; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.smallrye.common.annotation.Identifier; +import jakarta.inject.Inject; +import java.util.List; +import java.util.UUID; +import org.apache.polaris.core.PolarisCallContext; +import org.apache.polaris.core.config.PolarisConfigurationStore; +import org.apache.polaris.core.context.RealmContext; +import org.apache.polaris.core.entity.PolarisGrantRecord; +import org.apache.polaris.core.persistence.BaseResolverTest; +import org.apache.polaris.core.persistence.MetaStoreManagerFactory; +import org.apache.polaris.core.persistence.PolarisMetaStoreManager; +import org.apache.polaris.core.persistence.PolarisTestMetaStoreManager; +import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet; +import org.apache.polaris.ids.api.MonotonicClock; +import org.jboss.weld.junit5.EnableWeld; +import org.jboss.weld.junit5.WeldInitiator; +import org.jboss.weld.junit5.WeldSetup; +import org.junit.jupiter.api.BeforeAll; + +@SuppressWarnings("CdiInjectionPointsInspection") +@EnableWeld +public class TestPersistenceResolver extends BaseResolverTest { + @WeldSetup WeldInitiator weld = WeldInitiator.performDefaultDiscovery(); + + @Inject + @Identifier("nosql") + MetaStoreManagerFactory metaStoreManagerFactory; + + @Inject PolarisConfigurationStore configurationStore; + @Inject MonotonicClock monotonicClock; + + PolarisMetaStoreManager metaStoreManager; + + PolarisCallContext callCtx; + PolarisTestMetaStoreManager tm; + + String realmId; + RealmContext realmContext; + + @Override + protected PolarisCallContext callCtx() { + if (callCtx == null) { + realmId = UUID.randomUUID().toString(); + realmContext = () -> realmId; + + var startTime = monotonicClock.currentTimeMillis(); + + metaStoreManagerFactory.bootstrapRealms( + List.of(realmId), RootCredentialsSet.fromEnvironment()); + + metaStoreManager = metaStoreManagerFactory.getOrCreateMetaStoreManager(realmContext); + var session = metaStoreManagerFactory.getOrCreateSession(realmContext); + + callCtx = new PolarisCallContext(realmContext, session, configurationStore); + + tm = new PolarisTestMetaStoreManager(metaStoreManager, callCtx, startTime, false); + } + return callCtx; + } + + @Override + protected PolarisTestMetaStoreManager tm() { + callCtx(); + return tm; + } + + @Override + protected PolarisMetaStoreManager metaStoreManager() { + callCtx(); + return metaStoreManager; + } + + @Override + protected void checkRefGrantRecords( + List grantRecords, List refGrantRecords) { + assertThat(grantRecords).containsExactlyInAnyOrderElementsOf(refGrantRecords); + } + + @BeforeAll + public static void beforeAll() { + supportEntityCache = false; + } +} diff --git a/persistence/nosql/persistence/metastore/src/test/resources/logback-test.xml b/persistence/nosql/persistence/metastore/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..b0249025e4 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/test/resources/logback-test.xml @@ -0,0 +1,35 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + diff --git a/persistence/nosql/persistence/metastore/src/test/resources/weld.properties b/persistence/nosql/persistence/metastore/src/test/resources/weld.properties new file mode 100644 index 0000000000..c26169e0e1 --- /dev/null +++ b/persistence/nosql/persistence/metastore/src/test/resources/weld.properties @@ -0,0 +1,21 @@ +# +# 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. +# + +# See https://bugs.openjdk.org/browse/JDK-8349545 +org.jboss.weld.bootstrap.concurrentDeployment=false diff --git a/persistence/nosql/persistence/types/build.gradle.kts b/persistence/nosql/persistence/types/build.gradle.kts new file mode 100644 index 0000000000..3e67e91ab1 --- /dev/null +++ b/persistence/nosql/persistence/types/build.gradle.kts @@ -0,0 +1,80 @@ +/* + * 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris NoSQL persistence core types" + +dependencies { + implementation(project(":polaris-core")) + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-idgen-api")) + implementation(project(":polaris-persistence-nosql-authz-api")) + implementation(project(":polaris-persistence-nosql-varint")) + implementation(project(":polaris-persistence-nosql-maintenance-api")) + implementation(project(":polaris-persistence-nosql-maintenance-spi")) + implementation(project(":polaris-persistence-nosql-maintenance-cel")) + + implementation(libs.guava) + implementation(libs.slf4j.api) + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-annotations") + implementation("com.fasterxml.jackson.core:jackson-core") + implementation("com.fasterxml.jackson.core:jackson-databind") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-smile") + + compileOnly(libs.smallrye.config.core) + compileOnly(platform(libs.quarkus.bom)) + compileOnly("io.quarkus:quarkus-core") + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + testFixturesImplementation(project(":polaris-core")) + testRuntimeOnly(project(":polaris-persistence-nosql-authz-impl")) + testRuntimeOnly(project(":polaris-persistence-nosql-authz-store-nosql")) + + testFixturesImplementation(libs.jakarta.annotation.api) + testFixturesImplementation(libs.jakarta.validation.api) + testFixturesImplementation(libs.jakarta.enterprise.cdi.api) + testImplementation(libs.smallrye.common.annotation) + + testFixturesImplementation(platform(libs.jackson.bom)) + testFixturesImplementation("com.fasterxml.jackson.core:jackson-annotations") + testFixturesImplementation("com.fasterxml.jackson.core:jackson-core") + + testImplementation(testFixtures(project(":polaris-persistence-nosql-metastore"))) + testImplementation(project(":polaris-idgen-mocks")) + testImplementation(testFixtures(project(":polaris-persistence-nosql-maintenance-impl"))) + testImplementation(project(":polaris-persistence-nosql-impl")) + + testRuntimeOnly(testFixtures(project(":polaris-persistence-nosql-cdi-weld"))) + testImplementation(libs.weld.se.core) + testImplementation(libs.weld.junit5) + testRuntimeOnly(libs.smallrye.jandex) +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/ContainerObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/ContainerObj.java new file mode 100644 index 0000000000..c9716c693c --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/ContainerObj.java @@ -0,0 +1,51 @@ +/* + * 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.polaris.persistence.nosql.coretypes; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.BaseCommitObj; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; + +public interface ContainerObj extends BaseCommitObj { + /** Holds the mapping of fully qualified names to object references. */ + IndexContainer nameToObjRef(); + + /** + * Contains the mapping of {@linkplain ObjBase#stableId() stable IDs} to fully qualified names. + * + *

This index is meant as a temporary construct during the transition from numeric ID-based + * parent-child relationships and references to using fully qualified names as the primary + * identifier on all APIs (catalogs, catalog state, principals, etc.). + */ + IndexContainer stableIdToName(); + + interface Builder> + extends BaseCommitObj.Builder { + @CanIgnoreReturnValue + B from(ContainerObj container); + + @CanIgnoreReturnValue + B nameToObjRef(IndexContainer nameToObjRef); + + @CanIgnoreReturnValue + B stableIdToName(IndexContainer stableIdToName); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/ObjBase.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/ObjBase.java new file mode 100644 index 0000000000..36627d74e7 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/ObjBase.java @@ -0,0 +1,110 @@ +/* + * 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.polaris.persistence.nosql.coretypes; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.time.Instant; +import java.util.Map; +import org.apache.polaris.persistence.nosql.api.obj.Obj; +import org.immutables.value.Value; + +public interface ObjBase extends Obj { + + String name(); + + /** + * The stable ID for this object, remains the same for each update of the logical object. + * Raw persistence model {@link #id()} are unique for each persisted "version", because persisted + * objects are immutable. + * + *

This value is constant throughout the lifetime of the object (assigned once but never + * changed). + */ + long stableId(); + + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + @Value.Default + default long parentStableId() { + return 0L; + } + + @Value.Default + default int entityVersion() { + return 1; + } + + Instant createTimestamp(); + + Instant updateTimestamp(); + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + Map properties(); + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + Map internalProperties(); + + interface Builder> extends Obj.Builder { + + @CanIgnoreReturnValue + B from(ObjBase from); + + @CanIgnoreReturnValue + B stableId(long stableId); + + @CanIgnoreReturnValue + B entityVersion(int entityVersion); + + @CanIgnoreReturnValue + B name(String name); + + @CanIgnoreReturnValue + B parentStableId(long parentStableId); + + @CanIgnoreReturnValue + B createTimestamp(Instant createTimestamp); + + @CanIgnoreReturnValue + B updateTimestamp(Instant updateTimestamp); + + @CanIgnoreReturnValue + B putProperty(String key, String value); + + @CanIgnoreReturnValue + B putProperty(Map.Entry entry); + + @CanIgnoreReturnValue + B properties(Map entries); + + @CanIgnoreReturnValue + B putAllProperties(Map entries); + + @CanIgnoreReturnValue + B putInternalProperty(String key, String value); + + @CanIgnoreReturnValue + B putInternalProperty(Map.Entry entry); + + @CanIgnoreReturnValue + B internalProperties(Map entries); + + @CanIgnoreReturnValue + B putAllInternalProperties(Map entries); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/acl/AclObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/acl/AclObj.java new file mode 100644 index 0000000000..e16af0221d --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/acl/AclObj.java @@ -0,0 +1,67 @@ +/* + * 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.polaris.persistence.nosql.coretypes.acl; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.util.OptionalInt; +import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.Obj; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.authz.api.Acl; +import org.apache.polaris.persistence.nosql.coretypes.ObjBase; + +/** */ +@PolarisImmutable +@JsonSerialize(as = ImmutableAclObj.class) +@JsonDeserialize(as = ImmutableAclObj.class) +public interface AclObj extends Obj { + + ObjType TYPE = new AclObjType(); + + /** + * Refers to the {@linkplain PolarisBaseEntity#getId() entity id} / {@linkplain ObjBase#stableId() + * stable ID}. + */ + long securableId(); + + /** Refers to {@link org.apache.polaris.core.entity.PolarisEntityType}. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + OptionalInt securableTypeCode(); + + Acl acl(); + + @Override + default ObjType type() { + return TYPE; + } + + static ImmutableAclObj.Builder builder() { + return ImmutableAclObj.builder(); + } + + final class AclObjType extends AbstractObjType { + public AclObjType() { + super("acl", "ACL", AclObj.class); + } + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/acl/GrantsObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/acl/GrantsObj.java new file mode 100644 index 0000000000..fc8a982efb --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/acl/GrantsObj.java @@ -0,0 +1,39 @@ +/* + * 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.polaris.persistence.nosql.coretypes.acl; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.obj.BaseCommitObj; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; + +public interface GrantsObj extends BaseCommitObj { + + /** Index of securable keys to {@link AclObj}s. */ + IndexContainer acls(); + + interface Builder> + extends BaseCommitObj.Builder { + @CanIgnoreReturnValue + B from(GrantsObj container); + + @CanIgnoreReturnValue + B acls(IndexContainer acls); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogGrantsObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogGrantsObj.java new file mode 100644 index 0000000000..efc855c2e2 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogGrantsObj.java @@ -0,0 +1,58 @@ +/* + * 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.polaris.persistence.nosql.coretypes.catalog; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.acl.GrantsObj; + +/** + * Maintains the state of all catalog grants. The current version of this object is maintained via + * the reference name pattern {@value #CATALOG_GRANTS_REF_NAME_PATTERN}, where {@code %d} is to be + * replaced with the catalog's {@linkplain CatalogObj#stableId() stable ID}. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutableCatalogGrantsObj.class) +@JsonDeserialize(as = ImmutableCatalogGrantsObj.class) +public interface CatalogGrantsObj extends GrantsObj { + + String CATALOG_GRANTS_REF_NAME_PATTERN = "cat/%d/grants"; + + ObjType TYPE = new CatalogGrantsObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + static ImmutableCatalogGrantsObj.Builder builder() { + return ImmutableCatalogGrantsObj.builder(); + } + + final class CatalogGrantsObjType extends AbstractObjType { + public CatalogGrantsObjType() { + super("cat-gts", "Catalog Grants", CatalogGrantsObj.class); + } + } + + interface Builder extends GrantsObj.Builder {} +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogObj.java new file mode 100644 index 0000000000..9441f6d39c --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogObj.java @@ -0,0 +1,75 @@ +/* + * 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.polaris.persistence.nosql.coretypes.catalog; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.Optional; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; + +@PolarisImmutable +@JsonSerialize(as = ImmutableCatalogObj.class) +@JsonDeserialize(as = ImmutableCatalogObj.class) +public interface CatalogObj extends CatalogObjBase { + ObjType TYPE = new CatalogObjType(); + + CatalogType catalogType(); + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional defaultBaseLocation(); + + CatalogStatus status(); + + @Override + default ObjType type() { + return TYPE; + } + + static Builder builder() { + return ImmutableCatalogObj.builder(); + } + + final class CatalogObjType extends AbstractObjType { + public CatalogObjType() { + super("cat", "Catalog", CatalogObj.class); + } + } + + @SuppressWarnings("unused") + interface Builder extends CatalogObjBase.Builder { + @CanIgnoreReturnValue + Builder from(CatalogObj from); + + @CanIgnoreReturnValue + Builder status(CatalogStatus status); + + @CanIgnoreReturnValue + Builder catalogType(CatalogType catalogType); + + @CanIgnoreReturnValue + Builder defaultBaseLocation(String defaultBaseLocation); + + @CanIgnoreReturnValue + Builder defaultBaseLocation(Optional defaultBaseLocation); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogObjBase.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogObjBase.java new file mode 100644 index 0000000000..1d78e7a167 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogObjBase.java @@ -0,0 +1,54 @@ +/* + * 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.polaris.persistence.nosql.coretypes.catalog; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.Optional; +import org.apache.polaris.core.storage.PolarisStorageConfigurationInfo; +import org.apache.polaris.persistence.nosql.coretypes.ObjBase; + +public interface CatalogObjBase extends ObjBase { + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional storageConfigurationInfo(); + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional storageIntegrationIdentifier(); + + interface Builder> + extends ObjBase.Builder { + + @CanIgnoreReturnValue + B from(CatalogObjBase from); + + @CanIgnoreReturnValue + B storageConfigurationInfo(PolarisStorageConfigurationInfo storageConfigurationInfo); + + @CanIgnoreReturnValue + B storageConfigurationInfo( + Optional storageConfigurationInfo); + + @CanIgnoreReturnValue + B storageIntegrationIdentifier(String storageIntegrationIdentifier); + + @CanIgnoreReturnValue + B storageIntegrationIdentifier(Optional storageIntegrationIdentifier); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogRoleObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogRoleObj.java new file mode 100644 index 0000000000..67d8bb007d --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogRoleObj.java @@ -0,0 +1,54 @@ +/* + * 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.polaris.persistence.nosql.coretypes.catalog; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.ObjBase; + +@PolarisImmutable +@JsonSerialize(as = ImmutableCatalogRoleObj.class) +@JsonDeserialize(as = ImmutableCatalogRoleObj.class) +public interface CatalogRoleObj extends ObjBase { + ObjType TYPE = new CatalogRoleObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + static Builder builder() { + return ImmutableCatalogRoleObj.builder(); + } + + final class CatalogRoleObjType extends AbstractObjType { + public CatalogRoleObjType() { + super("cat-r", "Catalog Role", CatalogRoleObj.class); + } + } + + interface Builder extends ObjBase.Builder { + @CanIgnoreReturnValue + Builder from(CatalogRoleObj from); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogRolesObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogRolesObj.java new file mode 100644 index 0000000000..099a932f9f --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogRolesObj.java @@ -0,0 +1,69 @@ +/* + * 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.polaris.persistence.nosql.coretypes.catalog; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; + +/** + * Maintains the state of all catalog roles. The current version of this object is maintained via + * the reference name pattern {@value #CATALOG_ROLES_REF_NAME_PATTERN}, where {@code %d} is to be + * replaced with the catalog's {@linkplain CatalogObj#stableId() stable ID}. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutableCatalogRolesObj.class) +@JsonDeserialize(as = ImmutableCatalogRolesObj.class) +public interface CatalogRolesObj extends ContainerObj { + + String CATALOG_ROLES_REF_NAME_PATTERN = "cat/%d/roles"; + + ObjType TYPE = new CatalogRolesObjType(); + + /** Mapping of catalog role names to catalog role objects. */ + @Override + IndexContainer nameToObjRef(); + + // overridden only for posterity, no technical reason + @Override + IndexContainer stableIdToName(); + + @Override + default ObjType type() { + return TYPE; + } + + static ImmutableCatalogRolesObj.Builder builder() { + return ImmutableCatalogRolesObj.builder(); + } + + final class CatalogRolesObjType extends AbstractObjType { + public CatalogRolesObjType() { + super("cat-rls", "Catalog Roles", CatalogRolesObj.class); + } + } + + interface Builder extends ContainerObj.Builder {} +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogStateObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogStateObj.java new file mode 100644 index 0000000000..a95c4bd7c5 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogStateObj.java @@ -0,0 +1,109 @@ +/* + * 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.polaris.persistence.nosql.coretypes.catalog; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.util.Optional; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; +import org.apache.polaris.persistence.nosql.coretypes.ObjBase; +import org.apache.polaris.persistence.nosql.coretypes.changes.Change; + +/** + * Holds the state of all catalog entities. The current version of this object is maintained via the + * reference name pattern {@value #CATALOG_STATE_REF_NAME_PATTERN}, where {@code %d} is to be + * replaced with the catalog's {@linkplain CatalogObj#stableId() stable ID}. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutableCatalogStateObj.class) +@JsonDeserialize(as = ImmutableCatalogStateObj.class) +public interface CatalogStateObj extends ContainerObj { + + String CATALOG_STATE_REF_NAME_PATTERN = "cat/%d/heads/main"; + + ObjType TYPE = new CatalogStatesObjType(); + + /** + * Mapping of all entity names to catalog entities like namespaces, tables or views, which are + * accessible from this commit. + * + *

Think of this index as a {@code Map<}{@link IndexKey IndexKey}{@code ,}{@link ObjRef}{@code + * >}. + * + *

This index represents the current state of the catalog without any information about the + * change(s) that led to this state. Change information is maintained {@linkplain #changes() + * separately}. + * + *

Maintaining the {@linkplain #changes() changes} separately has the advantage that the + * storage needed for this index is not "wasted" with usually unneeded change information. + */ + @Override + IndexContainer nameToObjRef(); + + // overridden only for posterity, no technical reason + @Override + IndexContainer stableIdToName(); + + /** + * Contains detailed information about the changes performed in this particular commit. + * + *

The index here is used to provide literally only the changes, rather a logical + * representation than an exact 1:1 mapping of a {@link IndexKey} to some change. + * + *

There is no guarantee that all technically changed keys are mentioned individually in this + * index. Whether a change is contained depends on functional, not technical requirements. + * + *

Changes that affect many entities (bulk updates) may also be only recorded once. For + * example, renaming a namespace with implicit rename of the contained entities might only be + * mentioned once for the rename of the namespace, not for all contained entities. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional> changes(); + + /** + * Index of base-locations to {@link ObjBase#stableId() entity-IDs}. There can be multiple IDs for + * a single location if overlapping locations are allowed. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional> locations(); + + @Override + default ObjType type() { + return TYPE; + } + + static ImmutableCatalogStateObj.Builder builder() { + return ImmutableCatalogStateObj.builder(); + } + + final class CatalogStatesObjType extends AbstractObjType { + public CatalogStatesObjType() { + super("cat-st", "Catalog States", CatalogStateObj.class); + } + } + + interface Builder extends ContainerObj.Builder {} +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogStatus.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogStatus.java new file mode 100644 index 0000000000..cdaf4149d9 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogStatus.java @@ -0,0 +1,51 @@ +/* + * 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.polaris.persistence.nosql.coretypes.catalog; + +public enum CatalogStatus { + /** + * The initial state of a catalog is "created", which means that the catalog name is reserved, but + * the catalog is not yet usable. This state can transition to {@link #ACTIVE} or {@link + * #INACTIVE} or the catalog can be directly deleted. + */ + CREATED, + /** + * When a catalog is fully set up, its state is "active". This state can only transition to {@link + * #INACTIVE}. + */ + ACTIVE, + /** + * An {@link #ACTIVE} catalog can be put into "inactive" state, which means that the catalog + * cannot be used, but it can be put back into {@link #ACTIVE} state. + */ + INACTIVE, + /** + * An {@link #INACTIVE} catalog can be put into "purging" state, which means that the catalog's + * data is being purged from the persistence database. This is next to the final and terminal + * state {@link #PURGED} of a catalog. Once all data of the catalog has been purged, it must at + * least be set into {@link #PURGED} status or be entirely removed. + */ + PURGING, + /** + * "Purged" is the terminal state of every catalog. A purged catalog can be safely deleted. The + * difference between a "purged" catalog and a non-existing (deleted) catalog is that the name of + * a purged catalog name cannot be (re)used. + */ + PURGED, +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogType.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogType.java new file mode 100644 index 0000000000..f51ebfce0c --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogType.java @@ -0,0 +1,37 @@ +/* + * 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.polaris.persistence.nosql.coretypes.catalog; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; +import org.immutables.value.Value; + +@PolarisImmutable +@JsonSerialize(as = ImmutableCatalogType.class) +@JsonDeserialize(as = ImmutableCatalogType.class) +public interface CatalogType { + @JsonValue + @Value.Parameter + String name(); + + CatalogType INTERNAL = ImmutableCatalogType.of("INTERNAL"); + CatalogType EXTERNAL = ImmutableCatalogType.of("EXTERNAL"); +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogsObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogsObj.java new file mode 100644 index 0000000000..99001d39c0 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/CatalogsObj.java @@ -0,0 +1,68 @@ +/* + * 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.polaris.persistence.nosql.coretypes.catalog; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; + +/** + * Maintains the mapping of all catalogs by name to {@link CatalogObj}s. The current version of this + * object is maintained via the reference {@value #CATALOGS_REF_NAME}. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutableCatalogsObj.class) +@JsonDeserialize(as = ImmutableCatalogsObj.class) +public interface CatalogsObj extends ContainerObj { + + String CATALOGS_REF_NAME = "catalogs"; + + ObjType TYPE = new CatalogsObjType(); + + /** Mapping of catalog names to catalog objects. */ + @Override + IndexContainer nameToObjRef(); + + // overridden only for posterity, no technical reason + @Override + IndexContainer stableIdToName(); + + @Override + default ObjType type() { + return TYPE; + } + + static ImmutableCatalogsObj.Builder builder() { + return ImmutableCatalogsObj.builder(); + } + + final class CatalogsObjType extends AbstractObjType { + public CatalogsObjType() { + super("cats", "Catalogs", CatalogsObj.class); + } + } + + interface Builder extends ContainerObj.Builder {} +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/FileObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/FileObj.java new file mode 100644 index 0000000000..838fcfbb8d --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/FileObj.java @@ -0,0 +1,55 @@ +/* + * 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.polaris.persistence.nosql.coretypes.catalog; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.ObjBase; + +@PolarisImmutable +@JsonSerialize(as = ImmutableFileObj.class) +@JsonDeserialize(as = ImmutableFileObj.class) +public interface FileObj extends ObjBase { + ObjType TYPE = new FileObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + static Builder builder() { + return ImmutableFileObj.builder(); + } + + final class FileObjType extends AbstractObjType { + public FileObjType() { + super("file", "File", FileObj.class); + } + } + + interface Builder extends ObjBase.Builder { + + @CanIgnoreReturnValue + Builder from(FileObj from); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/LongValues.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/LongValues.java new file mode 100644 index 0000000000..285928de2e --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/LongValues.java @@ -0,0 +1,37 @@ +/* + * 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.polaris.persistence.nosql.coretypes.catalog; + +import java.util.Set; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; +import org.immutables.value.Value; + +@PolarisImmutable +public interface LongValues { + IndexValueSerializer LONG_VALUES_SERIALIZER = new LongValuesSerializer(); + + @Value.Parameter + Set entityIds(); + + static LongValues longValues(Set entityIds) { + return ImmutableLongValues.of(entityIds); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/LongValuesSerializer.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/LongValuesSerializer.java new file mode 100644 index 0000000000..8cbceb9b85 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/catalog/LongValuesSerializer.java @@ -0,0 +1,92 @@ +/* + * 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.polaris.persistence.nosql.coretypes.catalog; + +import static org.apache.polaris.persistence.varint.VarInt.putVarInt; +import static org.apache.polaris.persistence.varint.VarInt.readVarInt; +import static org.apache.polaris.persistence.varint.VarInt.readVarLong; +import static org.apache.polaris.persistence.varint.VarInt.skipVarInt; +import static org.apache.polaris.persistence.varint.VarInt.varIntLen; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.nio.ByteBuffer; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; + +final class LongValuesSerializer implements IndexValueSerializer { + private static final byte[] NULL; + + static { + var buffer = putVarInt(ByteBuffer.allocate(1), 0).flip(); + NULL = new byte[buffer.remaining()]; + buffer.get(NULL); + } + + @Nonnull + @Override + public ByteBuffer serialize(@Nullable LongValues value, @Nonnull ByteBuffer target) { + if (value == null) { + return ByteBuffer.wrap(NULL); + } + var longs = value.entityIds(); + var num = longs.size(); + putVarInt(target, num); + for (var l : longs) { + putVarInt(target, l); + } + return target; + } + + @Override + public int serializedSize(@Nullable LongValues value) { + if (value == null || value.entityIds().isEmpty()) { + return NULL.length; + } + var longs = value.entityIds(); + var num = longs.size(); + var size = varIntLen(num); + for (var l : longs) { + size += varIntLen(l); + } + return size; + } + + @Nullable + @Override + public LongValues deserialize(@Nonnull ByteBuffer buffer) { + var num = readVarInt(buffer); + if (num == 0) { + return null; + } + var b = ImmutableLongValues.builder(); + for (int i = 0; i < num; i++) { + b.addEntityId(readVarLong(buffer)); + } + return b.build(); + } + + @Override + public void skip(@Nonnull ByteBuffer buffer) { + var num = readVarInt(buffer); + for (int i = 0; i < num; i++) { + skipVarInt(buffer); + } + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/Change.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/Change.java new file mode 100644 index 0000000000..99db92126b --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/Change.java @@ -0,0 +1,36 @@ +/* + * 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.polaris.persistence.nosql.coretypes.changes; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.annotation.JsonTypeIdResolver; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; +import org.immutables.value.Value; + +@JsonTypeIdResolver(ChangeTypeIdResolver.class) +@JsonTypeInfo(use = JsonTypeInfo.Id.CUSTOM, property = "type", visible = true) +public interface Change { + IndexValueSerializer CHANGE_SERIALIZER = new ChangeSerializer(); + + @Value.Redacted + @JsonIgnore + // must use 'get*' here, otherwise the property won't be properly "wired" to be the type info + ChangeType getType(); +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeAdd.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeAdd.java new file mode 100644 index 0000000000..a20094df7a --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeAdd.java @@ -0,0 +1,37 @@ +/* + * 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.polaris.persistence.nosql.coretypes.changes; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; + +@PolarisImmutable +@JsonSerialize(as = ImmutableChangeAdd.class) +@JsonDeserialize(as = ImmutableChangeAdd.class) +public interface ChangeAdd extends Change { + @Override + default ChangeType getType() { + return ChangeType.ADD; + } + + static ImmutableChangeAdd.Builder builder() { + return ImmutableChangeAdd.builder(); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeRemove.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeRemove.java new file mode 100644 index 0000000000..489d9982b4 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeRemove.java @@ -0,0 +1,37 @@ +/* + * 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.polaris.persistence.nosql.coretypes.changes; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; + +@PolarisImmutable +@JsonSerialize(as = ImmutableChangeRemove.class) +@JsonDeserialize(as = ImmutableChangeRemove.class) +public interface ChangeRemove extends Change { + @Override + default ChangeType getType() { + return ChangeType.REMOVE; + } + + static ImmutableChangeRemove.Builder builder() { + return ImmutableChangeRemove.builder(); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeRename.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeRename.java new file mode 100644 index 0000000000..0ddbce3425 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeRename.java @@ -0,0 +1,37 @@ +/* + * 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.polaris.persistence.nosql.coretypes.changes; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; + +@PolarisImmutable +@JsonSerialize(as = ImmutableChangeRename.class) +@JsonDeserialize(as = ImmutableChangeRename.class) +public interface ChangeRename extends ChangeRenameBase { + @Override + default ChangeType getType() { + return ChangeType.RENAME; + } + + static ImmutableChangeRename.Builder builder() { + return ImmutableChangeRename.builder(); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeRenameBase.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeRenameBase.java new file mode 100644 index 0000000000..24b639d6cc --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeRenameBase.java @@ -0,0 +1,26 @@ +/* + * 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.polaris.persistence.nosql.coretypes.changes; + +import org.apache.polaris.persistence.nosql.api.index.IndexKey; + +public interface ChangeRenameBase extends Change { + + IndexKey renameFrom(); +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeSerializer.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeSerializer.java new file mode 100644 index 0000000000..9c7db1412d --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeSerializer.java @@ -0,0 +1,94 @@ +/* + * 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.polaris.persistence.nosql.coretypes.changes; + +import static org.apache.polaris.persistence.varint.VarInt.putVarInt; +import static org.apache.polaris.persistence.varint.VarInt.readVarInt; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.util.ByteBufferBackedInputStream; +import com.fasterxml.jackson.dataformat.smile.databind.SmileMapper; +import com.google.common.io.CountingOutputStream; +import com.google.common.primitives.Ints; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; + +/** + * Index element value serializer for {@link Change} objects. + * + *

Delegates to the rather "expensive" and "verbose" Jackson/Smile serialization, in contrast to + * the space-optimized {@link ObjRef#OBJ_REF_SERIALIZER}. The reason for that implementation choice + * is that change serialization needs to be rather flexible, but also because space efficiency is + * not really a concern for the set of changes that have been done within a commit - there is + * usually just one changed entity per commit. + */ +final class ChangeSerializer implements IndexValueSerializer { + static ObjectMapper MAPPER = + new SmileMapper() + .findAndRegisterModules() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + @Override + public int serializedSize(@Nullable Change value) { + try (var out = new CountingOutputStream(OutputStream.nullOutputStream())) { + MAPPER.writeValue(out, value); + return Ints.checkedCast(out.getCount()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Nonnull + @Override + public ByteBuffer serialize(@Nullable Change value, @Nonnull ByteBuffer target) { + try { + var bytes = MAPPER.writeValueAsBytes(value); + putVarInt(target, bytes.length); + target.put(bytes); + return target; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Nullable + @Override + public Change deserialize(@Nonnull ByteBuffer buffer) { + try { + var len = readVarInt(buffer); + var readBuf = buffer.duplicate().limit(buffer.position() + len); + buffer.position(buffer.position() + len); + return MAPPER.readValue(new ByteBufferBackedInputStream(readBuf), Change.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void skip(@Nonnull ByteBuffer buffer) { + var len = readVarInt(buffer); + buffer.position(buffer.position() + len); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeType.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeType.java new file mode 100644 index 0000000000..a560413464 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeType.java @@ -0,0 +1,55 @@ +/* + * 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.polaris.persistence.nosql.coretypes.changes; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.util.Map; +import org.apache.polaris.immutables.PolarisImmutable; +import org.immutables.value.Value; + +@PolarisImmutable +@JsonDeserialize(as = ImmutableChangeType.class) +@JsonSerialize(as = ImmutableChangeType.class) +public interface ChangeType { + ChangeType ADD = valueOf("add"); + ChangeType UPDATE = valueOf("update"); + ChangeType REMOVE = valueOf("remove"); + ChangeType RENAME = valueOf("rename"); + + Map> TYPE_MAP = + Map.of( + ADD.name(), + ChangeAdd.class, + UPDATE.name(), + ChangeUpdate.class, + REMOVE.name(), + ChangeRemove.class, + RENAME.name(), + ChangeRename.class); + + @JsonValue + @Value.Parameter + String name(); + + static ChangeType valueOf(String name) { + return ImmutableChangeType.of(name); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeTypeIdResolver.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeTypeIdResolver.java new file mode 100644 index 0000000000..f3c82c4c5e --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeTypeIdResolver.java @@ -0,0 +1,81 @@ +/* + * 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.polaris.persistence.nosql.coretypes.changes; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.DatabindContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.jsontype.impl.TypeIdResolverBase; +import java.util.Locale; + +final class ChangeTypeIdResolver extends TypeIdResolverBase { + + private JavaType baseType; + + public ChangeTypeIdResolver() {} + + @Override + public void init(JavaType bt) { + baseType = bt; + } + + @Override + public String idFromValue(Object value) { + return getId(value); + } + + @Override + public String idFromValueAndType(Object value, Class suggestedType) { + return getId(value); + } + + @Override + public JsonTypeInfo.Id getMechanism() { + return JsonTypeInfo.Id.CUSTOM; + } + + private String getId(Object value) { + if (value instanceof Change change) { + return change.getType().name(); + } + + return null; + } + + @Override + public JavaType typeFromId(DatabindContext context, String id) { + var idLower = id.toLowerCase(Locale.ROOT); + var asType = ChangeType.TYPE_MAP.get(idLower); + if (asType == null) { + return context.constructSpecializedType(baseType, GenericChange.class); + } + if (baseType.getRawClass().isAssignableFrom(asType)) { + return context.constructSpecializedType(baseType, asType); + } + + // This is rather a "test-only" code path, but it might happen in real life as well, when + // calling the ObjectMapper with a "too specific" type and not just Change.class. + // So we can get here, for example, if the baseType (induced by the type passed to + // ObjectMapper), is GenericChange.class, but the type is a "well known" type like + // ChangeRename.class. + @SuppressWarnings("unchecked") + var concrete = (Class) baseType.getRawClass(); + return context.constructSpecializedType(baseType, concrete); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeUpdate.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeUpdate.java new file mode 100644 index 0000000000..84bd2148b4 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/ChangeUpdate.java @@ -0,0 +1,37 @@ +/* + * 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.polaris.persistence.nosql.coretypes.changes; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; + +@PolarisImmutable +@JsonSerialize(as = ImmutableChangeUpdate.class) +@JsonDeserialize(as = ImmutableChangeUpdate.class) +public interface ChangeUpdate extends Change { + @Override + default ChangeType getType() { + return ChangeType.UPDATE; + } + + static ImmutableChangeUpdate.Builder builder() { + return ImmutableChangeUpdate.builder(); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/GenericChange.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/GenericChange.java new file mode 100644 index 0000000000..fe3f7754cb --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/changes/GenericChange.java @@ -0,0 +1,91 @@ +/* + * 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.polaris.persistence.nosql.coretypes.changes; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.jsontype.TypeSerializer; +import java.io.IOException; +import java.util.Map; +import org.apache.polaris.immutables.PolarisImmutable; +import org.immutables.value.Value; + +/** + * "Generic" change, only to be used as a fallback when hitting an unknown change type during + * deserialization. + */ +@PolarisImmutable +@JsonSerialize(using = GenericChange.GenericChangeInfoSerializer.class) +@JsonDeserialize(using = GenericChange.GenericChangeInfoDeserializer.class) +public interface GenericChange extends Change { + @Override + @Value.Parameter + ChangeType getType(); + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @JsonUnwrapped + @Value.Parameter + Map getAttributes(); + + final class GenericChangeInfoSerializer extends JsonSerializer { + + @Override + public void serializeWithType( + GenericChange value, + JsonGenerator gen, + SerializerProvider serializers, + TypeSerializer typeSer) + throws IOException { + gen.writeStartObject(); + gen.writeStringField("type", value.getType().name()); + var attributes = value.getAttributes(); + if (attributes != null) { + for (var entry : attributes.entrySet()) { + gen.writeFieldName(entry.getKey()); + gen.writeObject(entry.getValue()); + } + } + gen.writeEndObject(); + } + + @Override + public void serialize(GenericChange value, JsonGenerator gen, SerializerProvider serializers) { + throw new UnsupportedOperationException(); + } + } + + final class GenericChangeInfoDeserializer extends JsonDeserializer { + + @SuppressWarnings("unchecked") + @Override + public GenericChange deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + var all = p.readValueAs(Map.class); + var type = (String) all.remove("type"); + return ImmutableGenericChange.of(ChangeType.valueOf(type), all); + } + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/ContentObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/ContentObj.java new file mode 100644 index 0000000000..3fe74d7193 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/ContentObj.java @@ -0,0 +1,41 @@ +/* + * 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.polaris.persistence.nosql.coretypes.content; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.time.Instant; +import java.util.Optional; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogObjBase; + +/** Base for all catalog content objects, including namespaces, tables, views, etc. */ +public interface ContentObj extends CatalogObjBase { + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonFormat(shape = JsonFormat.Shape.NUMBER) + Optional lastNotificationTimestamp(); + + interface Builder> + extends CatalogObjBase.Builder { + + @CanIgnoreReturnValue + B from(ContentObj from); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/GenericTableObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/GenericTableObj.java new file mode 100644 index 0000000000..68079c944b --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/GenericTableObj.java @@ -0,0 +1,75 @@ +/* + * 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.polaris.persistence.nosql.coretypes.content; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.Optional; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; + +@PolarisImmutable +@JsonSerialize(as = ImmutableGenericTableObj.class) +@JsonDeserialize(as = ImmutableGenericTableObj.class) +public interface GenericTableObj extends TableLikeObj { + ObjType TYPE = new GenericTableObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional format(); + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional doc(); + + static Builder builder() { + return ImmutableGenericTableObj.builder(); + } + + final class GenericTableObjType extends AbstractObjType { + public GenericTableObjType() { + super("gen-tab", "GenericTable", GenericTableObj.class); + } + } + + @SuppressWarnings("unused") + interface Builder extends TableLikeObj.Builder { + + @CanIgnoreReturnValue + Builder from(GenericTableObj from); + + @CanIgnoreReturnValue + Builder format(String format); + + @CanIgnoreReturnValue + Builder format(Optional format); + + @CanIgnoreReturnValue + Builder doc(String doc); + + @CanIgnoreReturnValue + Builder doc(Optional doc); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/IcebergTableLikeObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/IcebergTableLikeObj.java new file mode 100644 index 0000000000..0ac986b8a4 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/IcebergTableLikeObj.java @@ -0,0 +1,32 @@ +/* + * 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.polaris.persistence.nosql.coretypes.content; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; + +/** Base for all Iceberg specific catalog content objects like Iceberg tables and Iceberg views. */ +public interface IcebergTableLikeObj extends TableLikeObj { + + interface Builder> + extends TableLikeObj.Builder { + + @CanIgnoreReturnValue + B from(IcebergTableLikeObj from); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/IcebergTableObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/IcebergTableObj.java new file mode 100644 index 0000000000..e74884611d --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/IcebergTableObj.java @@ -0,0 +1,54 @@ +/* + * 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.polaris.persistence.nosql.coretypes.content; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; + +@PolarisImmutable +@JsonSerialize(as = ImmutableIcebergTableObj.class) +@JsonDeserialize(as = ImmutableIcebergTableObj.class) +public interface IcebergTableObj extends IcebergTableLikeObj { + ObjType TYPE = new IcebergTableObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + static Builder builder() { + return ImmutableIcebergTableObj.builder(); + } + + final class IcebergTableObjType extends AbstractObjType { + public IcebergTableObjType() { + super("i-t", "Iceberg Table", IcebergTableObj.class); + } + } + + interface Builder extends IcebergTableLikeObj.Builder { + + @CanIgnoreReturnValue + Builder from(IcebergTableObj from); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/IcebergViewObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/IcebergViewObj.java new file mode 100644 index 0000000000..094b2c3b7d --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/IcebergViewObj.java @@ -0,0 +1,54 @@ +/* + * 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.polaris.persistence.nosql.coretypes.content; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; + +@PolarisImmutable +@JsonSerialize(as = ImmutableIcebergViewObj.class) +@JsonDeserialize(as = ImmutableIcebergViewObj.class) +public interface IcebergViewObj extends IcebergTableLikeObj { + ObjType TYPE = new IcebergViewObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + static Builder builder() { + return ImmutableIcebergViewObj.builder(); + } + + final class IcebergViewObjType extends AbstractObjType { + public IcebergViewObjType() { + super("i-v", "Iceberg View", IcebergViewObj.class); + } + } + + interface Builder extends IcebergTableLikeObj.Builder { + + @CanIgnoreReturnValue + Builder from(IcebergViewObj from); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/LocalNamespaceObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/LocalNamespaceObj.java new file mode 100644 index 0000000000..b89245d49f --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/LocalNamespaceObj.java @@ -0,0 +1,55 @@ +/* + * 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.polaris.persistence.nosql.coretypes.content; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; + +/** Locally managed namespace. */ +@PolarisImmutable +@JsonSerialize(as = ImmutableLocalNamespaceObj.class) +@JsonDeserialize(as = ImmutableLocalNamespaceObj.class) +public interface LocalNamespaceObj extends NamespaceObj { + ObjType TYPE = new LocalNamespaceObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + static Builder builder() { + return ImmutableLocalNamespaceObj.builder(); + } + + final class LocalNamespaceObjType extends AbstractObjType { + public LocalNamespaceObjType() { + super("ns-l", "Namespace (local)", LocalNamespaceObj.class); + } + } + + interface Builder extends NamespaceObj.Builder { + + @CanIgnoreReturnValue + Builder from(LocalNamespaceObj from); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/NamespaceObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/NamespaceObj.java new file mode 100644 index 0000000000..324a1cc45d --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/NamespaceObj.java @@ -0,0 +1,33 @@ +/* + * 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.polaris.persistence.nosql.coretypes.content; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; + +/** Base for all namespaces (think: folder). */ +public interface NamespaceObj extends PolicyAttachableContentObj { + + interface Builder< + O extends PolicyAttachableContentObj, B extends PolicyAttachableContentObj.Builder> + extends PolicyAttachableContentObj.Builder { + + @CanIgnoreReturnValue + B from(NamespaceObj from); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/PolicyAttachableContentObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/PolicyAttachableContentObj.java new file mode 100644 index 0000000000..907626570b --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/PolicyAttachableContentObj.java @@ -0,0 +1,31 @@ +/* + * 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.polaris.persistence.nosql.coretypes.content; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; + +public interface PolicyAttachableContentObj extends ContentObj { + + interface Builder> + extends ContentObj.Builder { + + @CanIgnoreReturnValue + B from(PolicyAttachableContentObj from); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/PolicyObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/PolicyObj.java new file mode 100644 index 0000000000..ff38aee4e9 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/PolicyObj.java @@ -0,0 +1,86 @@ +/* + * 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.polaris.persistence.nosql.coretypes.content; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.Optional; +import java.util.OptionalInt; +import org.apache.polaris.core.policy.PolicyType; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; + +@PolarisImmutable +@JsonSerialize(as = ImmutablePolicyObj.class) +@JsonDeserialize(as = ImmutablePolicyObj.class) +public interface PolicyObj extends ContentObj { + ObjType TYPE = new CatalogPolicyObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + PolicyType policyType(); + + Optional description(); + + Optional content(); + + OptionalInt policyVersion(); + + static Builder builder() { + return ImmutablePolicyObj.builder(); + } + + final class CatalogPolicyObjType extends AbstractObjType { + public CatalogPolicyObjType() { + super("pol", "Policy", PolicyObj.class); + } + } + + @SuppressWarnings("unused") + interface Builder extends ContentObj.Builder { + @CanIgnoreReturnValue + Builder from(PolicyObj from); + + @CanIgnoreReturnValue + Builder policyType(PolicyType policyType); + + @CanIgnoreReturnValue + Builder description(String description); + + @CanIgnoreReturnValue + Builder description(Optional description); + + @CanIgnoreReturnValue + Builder content(String content); + + @CanIgnoreReturnValue + Builder content(Optional content); + + @CanIgnoreReturnValue + Builder policyVersion(int policyVersion); + + @CanIgnoreReturnValue + Builder policyVersion(OptionalInt policyVersion); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/RemoteNamespaceObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/RemoteNamespaceObj.java new file mode 100644 index 0000000000..1f955eae00 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/RemoteNamespaceObj.java @@ -0,0 +1,55 @@ +/* + * 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.polaris.persistence.nosql.coretypes.content; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; + +/** Namespace that represents a remote catalog. */ +@PolarisImmutable +@JsonSerialize(as = ImmutableRemoteNamespaceObj.class) +@JsonDeserialize(as = ImmutableRemoteNamespaceObj.class) +public interface RemoteNamespaceObj extends NamespaceObj { + ObjType TYPE = new RemoteNamespaceObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + static Builder builder() { + return ImmutableRemoteNamespaceObj.builder(); + } + + final class RemoteNamespaceObjType extends AbstractObjType { + public RemoteNamespaceObjType() { + super("ns-r", "Namespace (remote)", RemoteNamespaceObj.class); + } + } + + interface Builder extends NamespaceObj.Builder { + + @CanIgnoreReturnValue + Builder from(RemoteNamespaceObj from); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/TableLikeObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/TableLikeObj.java new file mode 100644 index 0000000000..9e3bbce279 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/content/TableLikeObj.java @@ -0,0 +1,40 @@ +/* + * 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.polaris.persistence.nosql.coretypes.content; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.Optional; + +/** Base for all catalog content objects, including namespaces, tables, views, etc. */ +public interface TableLikeObj extends PolicyAttachableContentObj { + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional metadataLocation(); + + interface Builder> + extends PolicyAttachableContentObj.Builder { + + @CanIgnoreReturnValue + B from(TableLikeObj from); + + @CanIgnoreReturnValue + B metadataLocation(String metadataLocation); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/CatalogRetainedIdentifier.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/CatalogRetainedIdentifier.java new file mode 100644 index 0000000000..41cb28ae6f --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/CatalogRetainedIdentifier.java @@ -0,0 +1,340 @@ +/* + * 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.polaris.persistence.nosql.coretypes.maintenance; + +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.coretypes.realm.PolicyMapping.POLICY_MAPPING_SERIALIZER; +import static org.apache.polaris.persistence.nosql.coretypes.refs.References.perCatalogReferenceName; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.function.Function; +import org.apache.polaris.ids.api.MonotonicClock; +import org.apache.polaris.maintenance.cel.CelReferenceContinuePredicate; +import org.apache.polaris.persistence.nosql.api.exceptions.ReferenceNotFoundException; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.BaseCommitObj; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogGrantsObj; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogObj; +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.principals.PrincipalRolesObj; +import org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalsObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.ImmediateTasksObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.PolicyMappingsObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.RealmGrantsObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.RootObj; +import org.apache.polaris.persistence.nosql.maintenance.spi.PerRealmRetainedIdentifier; +import org.apache.polaris.persistence.nosql.maintenance.spi.RetainedCollector; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +class CatalogRetainedIdentifier implements PerRealmRetainedIdentifier { + + private static final Logger LOGGER = LoggerFactory.getLogger(CatalogRetainedIdentifier.class); + + private final CatalogsMaintenanceConfig catalogsMaintenanceConfig; + private final MonotonicClock monotonicClock; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + CatalogRetainedIdentifier( + CatalogsMaintenanceConfig catalogsMaintenanceConfig, MonotonicClock monotonicClock) { + this.catalogsMaintenanceConfig = catalogsMaintenanceConfig; + this.monotonicClock = monotonicClock; + } + + @Override + public String name() { + return "Catalog data"; + } + + @Override + public boolean identifyRetained(@Nonnull RetainedCollector collector) { + + // Note: References & objects retrieved via the `Persistence` instance returned by the + // `RetainedCollector` are automatically retained (no need to call collector.retain*() + // explicitly). + var persistence = collector.realmPersistence(); + + // per realm + + // The root object is "special" (there's only one) + LOGGER.info("Identifying root object..."); + ignoreReferenceNotFound( + () -> persistence.fetchReferenceHead(RootObj.ROOT_REF_NAME, RootObj.class)); + + perRealmContainer( + "principals", + PrincipalsObj.PRINCIPALS_REF_NAME, + catalogsMaintenanceConfig + .principalsRetain() + .orElse(CatalogsMaintenanceConfig.DEFAULT_PRINCIPALS_RETAIN), + PrincipalsObj.class, + collector); + + perRealmContainer( + "principal roles", + PrincipalRolesObj.PRINCIPAL_ROLES_REF_NAME, + catalogsMaintenanceConfig + .principalRolesRetain() + .orElse(CatalogsMaintenanceConfig.DEFAULT_PRINCIPAL_ROLES_RETAIN), + PrincipalRolesObj.class, + collector); + + perRealm( + "grants", + RealmGrantsObj.REALM_GRANTS_REF_NAME, + catalogsMaintenanceConfig + .grantsRetain() + .orElse(CatalogsMaintenanceConfig.DEFAULT_GRANTS_RETAIN), + RealmGrantsObj.class, + RealmGrantsObj::acls, + collector); + + perRealmContainer( + "immediate tasks", + ImmediateTasksObj.IMMEDIATE_TASKS_REF_NAME, + catalogsMaintenanceConfig + .immediateTasksRetain() + .orElse(CatalogsMaintenanceConfig.DEFAULT_IMMEDIATE_TASKS_RETAIN), + ImmediateTasksObj.class, + collector); + + LOGGER.info("Identifying policy mappings..."); + ignoreReferenceNotFound( + () -> { + var policyMappingsContinue = + new CelReferenceContinuePredicate( + PolicyMappingsObj.POLICY_MAPPINGS_REF_NAME, + persistence, + catalogsMaintenanceConfig + .catalogPoliciesRetain() + .orElse(CatalogsMaintenanceConfig.DEFAULT_CATALOG_POLICIES_RETAIN)); + // PolicyMappings are stored _INLINE_ + collector.refRetain( + PolicyMappingsObj.POLICY_MAPPINGS_REF_NAME, + PolicyMappingsObj.class, + policyMappingsContinue, + policyMappingsObj -> + policyMappingsObj + .policyMappings() + .indexForRead(collector.realmPersistence(), POLICY_MAPPING_SERIALIZER) + .forEach( + e -> { + var policyMapping = e.getValue(); + policyMapping.externalMapping().ifPresent(collector::retainObject); + })); + }); + + // per catalog + + LOGGER.info("Identifying catalogs..."); + ignoreReferenceNotFound( + () -> { + var catalogsHistoryContinue = + new CelReferenceContinuePredicate( + CatalogsObj.CATALOGS_REF_NAME, + persistence, + catalogsMaintenanceConfig + .catalogsHistoryRetain() + .orElse(CatalogsMaintenanceConfig.DEFAULT_CATALOGS_HISTORY_RETAIN)); + var currentCatalogs = new ConcurrentHashMap(); + collector.refRetain( + CatalogsObj.CATALOGS_REF_NAME, + CatalogsObj.class, + catalogsHistoryContinue, + catalogs -> { + var allCatalogsIndex = + catalogs.nameToObjRef().indexForRead(persistence, OBJ_REF_SERIALIZER); + for (var entry : allCatalogsIndex) { + var catalogKey = entry.getKey(); + var catalogObjRef = entry.getValue(); + currentCatalogs.putIfAbsent(catalogKey, catalogObjRef); + } + collector.indexRetain(catalogs.stableIdToName()); + }); + + var catalogObjs = + persistence.fetchMany( + CatalogObj.class, currentCatalogs.values().toArray(ObjRef[]::new)); + for (var catalogObj : catalogObjs) { + if (catalogObj == null) { + // just in case... + continue; + } + + perCatalog( + "catalog roles", + CatalogRolesObj.CATALOG_ROLES_REF_NAME_PATTERN, + catalogObj, + catalogsMaintenanceConfig + .catalogRolesRetain() + .orElse(CatalogsMaintenanceConfig.DEFAULT_CATALOG_ROLES_RETAIN), + CatalogRolesObj.class, + CatalogRolesObj::nameToObjRef, + collector, + catalogRolesObj -> collector.indexRetain(catalogRolesObj.stableIdToName())); + + perCatalog( + "catalog grants", + CatalogGrantsObj.CATALOG_GRANTS_REF_NAME_PATTERN, + catalogObj, + catalogsMaintenanceConfig + .immediateTasksRetain() + .orElse(CatalogsMaintenanceConfig.DEFAULT_GRANTS_RETAIN), + CatalogGrantsObj.class, + CatalogGrantsObj::acls, + collector, + o -> {}); + + LOGGER.info( + "Identifying catalog state for catalog '{}' ({})...", + catalogObj.name(), + catalogObj.stableId()); + ignoreReferenceNotFound( + () -> { + var catalogStateRefName = + perCatalogReferenceName( + CatalogStateObj.CATALOG_STATE_REF_NAME_PATTERN, catalogObj.stableId()); + var catalogStateContinue = + new CelReferenceContinuePredicate( + catalogStateRefName, + persistence, + catalogsMaintenanceConfig + .catalogStateRetain() + .orElse(CatalogsMaintenanceConfig.DEFAULT_CATALOG_STATE_RETAIN)); + collector.refRetainIndexToSingleObj( + catalogStateRefName, + CatalogStateObj.class, + catalogStateContinue, + CatalogStateObj::nameToObjRef, + new RetainedCollector.ProgressListener<>() { + private long commit; + private long nextLog = monotonicClock.currentTimeMillis() + 2_000L; + + @Override + public void onCommit(CatalogStateObj catalogStateObj, long commit) { + collector.indexRetain(catalogStateObj.stableIdToName()); + catalogStateObj.locations().ifPresent(collector::indexRetain); + catalogStateObj.changes().ifPresent(collector::indexRetain); + this.commit = commit; + } + + @Override + public void onIndexEntry(long inCommit, long total) { + var now = monotonicClock.currentTimeMillis(); + if (now >= nextLog) { + LOGGER.info( + "... {} total index entries processed so far, at commit {}", + total, + commit); + nextLog = now + 2_000L; + } + } + }, + x -> {}); + }); + } + }); + + return true; + } + + @SuppressWarnings({"LoggingSimilarMessage", "SameParameterValue"}) + private void perRealm( + String what, + String refName, + String celRetainExpr, + Class objClazz, + Function> indexContainerFunction, + RetainedCollector collector) { + + LOGGER.info("Identifying {}...", what); + ignoreReferenceNotFound( + () -> { + var persistence = collector.realmPersistence(); + var historyContinue = + new CelReferenceContinuePredicate(refName, persistence, celRetainExpr); + collector.refRetainIndexToSingleObj( + refName, objClazz, historyContinue, indexContainerFunction); + }); + } + + @SuppressWarnings("LoggingSimilarMessage") + private void perRealmContainer( + String what, + String refName, + String celRetainExpr, + Class objClazz, + RetainedCollector collector) { + + LOGGER.info("Identifying {}...", what); + ignoreReferenceNotFound( + () -> { + var persistence = collector.realmPersistence(); + var historyContinue = + new CelReferenceContinuePredicate(refName, persistence, celRetainExpr); + collector.refRetainIndexToSingleObj( + refName, + objClazz, + historyContinue, + ContainerObj::nameToObjRef, + containerObj -> collector.indexRetain(containerObj.stableIdToName())); + }); + } + + private void perCatalog( + String what, + String refNamePattern, + CatalogObj catalogObj, + String celRetainExpr, + Class objClazz, + Function> indexContainerFunction, + RetainedCollector collector, + Consumer objConsumer) { + LOGGER.info( + "Identifying {} for catalog '{}' ({})...", what, catalogObj.name(), catalogObj.stableId()); + ignoreReferenceNotFound( + () -> { + var persistence = collector.realmPersistence(); + var refName = perCatalogReferenceName(refNamePattern, catalogObj.stableId()); + var historyContinue = + new CelReferenceContinuePredicate(refName, persistence, celRetainExpr); + collector.refRetainIndexToSingleObj( + refName, objClazz, historyContinue, indexContainerFunction, objConsumer); + }); + } + + void ignoreReferenceNotFound(Runnable runnable) { + try { + runnable.run(); + } catch (ReferenceNotFoundException e) { + LOGGER.debug("Reference not found: {}", e.getMessage()); + } + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/CatalogsMaintenanceConfig.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/CatalogsMaintenanceConfig.java new file mode 100644 index 0000000000..f8fff94bd7 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/CatalogsMaintenanceConfig.java @@ -0,0 +1,103 @@ +/* + * 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.polaris.persistence.nosql.coretypes.maintenance; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import java.util.Optional; +import org.apache.polaris.immutables.PolarisImmutable; + +/** + * Polaris stores a history of changes per kind of object (principals, principal roles, grants, + * immediate tasks, catalog roles and catalog state). + * + *

The rules are defined using a CEL + * script. The default rules for all kinds of objects are to retain the history for 3 days, for + * the catalog state for 30 days. + * + *

The scripts have access to the following declared values: + * + *

    + *
  • {@code ref} (string) name of the reference + *
  • {@code commits} (64-bit int) number of the currently processed commit, starting at {@code + * 1} + *
  • {@code ageDays} (64-bit int) age of currently processed commit in days + *
  • {@code ageHours} (64-bit int) age of currently processed commit in hours + *
  • {@code ageMinutes} (64-bit int) age of currently processed commit in minutes + *
+ * + *

Scripts must return a {@code boolean} yielding whether the commit shall be retained. + * Note that maintenance-service implementations can keep the first not-to-be-retained commit. + * + *

Example scripts + * + *

    + *
  • {@code ageDays < 30 || commits <= 10} retains the reference history with at least 10 + * commits and commits that are younger than 30 days + *
  • {@code true} retains the whole reference history + *
  • {@code false} retains the most recent commit + *
+ */ +@ConfigMapping(prefix = "polaris.persistence.maintenance.catalog") +@JsonSerialize(as = ImmutableBuildableCatalogsMaintenanceConfig.class) +@JsonDeserialize(as = ImmutableBuildableCatalogsMaintenanceConfig.class) +public interface CatalogsMaintenanceConfig { + + String DEFAULT_PRINCIPALS_RETAIN = "false"; + String DEFAULT_PRINCIPAL_ROLES_RETAIN = "false"; + String DEFAULT_GRANTS_RETAIN = "false"; + String DEFAULT_IMMEDIATE_TASKS_RETAIN = "false"; + String DEFAULT_CATALOGS_HISTORY_RETAIN = "false"; + String DEFAULT_CATALOG_ROLES_RETAIN = "false"; + String DEFAULT_CATALOG_POLICIES_RETAIN = "ageDays < 30 || commits <= 1"; + String DEFAULT_CATALOG_STATE_RETAIN = "ageDays < 30 || commits <= 1"; + + @WithDefault(DEFAULT_PRINCIPALS_RETAIN) + Optional principalsRetain(); + + @WithDefault(DEFAULT_PRINCIPAL_ROLES_RETAIN) + Optional principalRolesRetain(); + + @WithDefault(DEFAULT_GRANTS_RETAIN) + Optional grantsRetain(); + + @WithDefault(DEFAULT_IMMEDIATE_TASKS_RETAIN) + Optional immediateTasksRetain(); + + @WithDefault(DEFAULT_CATALOGS_HISTORY_RETAIN) + Optional catalogsHistoryRetain(); + + @WithDefault(DEFAULT_CATALOG_ROLES_RETAIN) + Optional catalogRolesRetain(); + + @WithDefault(DEFAULT_CATALOG_POLICIES_RETAIN) + Optional catalogPoliciesRetain(); + + @WithDefault(DEFAULT_CATALOG_STATE_RETAIN) + Optional catalogStateRetain(); + + @PolarisImmutable + interface BuildableCatalogsMaintenanceConfig extends CatalogsMaintenanceConfig { + static ImmutableBuildableCatalogsMaintenanceConfig.Builder builder() { + return ImmutableBuildableCatalogsMaintenanceConfig.builder(); + } + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/principals/PrincipalObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/principals/PrincipalObj.java new file mode 100644 index 0000000000..d0fcfcf106 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/principals/PrincipalObj.java @@ -0,0 +1,103 @@ +/* + * 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.polaris.persistence.nosql.coretypes.principals; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.Optional; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.ObjBase; +import org.immutables.value.Value; + +@PolarisImmutable +@JsonSerialize(as = ImmutablePrincipalObj.class) +@JsonDeserialize(as = ImmutablePrincipalObj.class) +public interface PrincipalObj extends ObjBase { + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional clientId(); + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional mainSecretHash(); + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional secondarySecretHash(); + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional secretSalt(); + + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + @Value.Default + default boolean credentialRotationRequired() { + return false; + } + + ObjType TYPE = new PrincipalObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + static Builder builder() { + return ImmutablePrincipalObj.builder(); + } + + final class PrincipalObjType extends AbstractObjType { + public PrincipalObjType() { + super("pr", "Principal", PrincipalObj.class); + } + } + + interface Builder extends ObjBase.Builder { + + @CanIgnoreReturnValue + Builder from(PrincipalObj from); + + @CanIgnoreReturnValue + Builder mainSecretHash(String mainSecretHash); + + @CanIgnoreReturnValue + Builder mainSecretHash(Optional mainSecretHash); + + @CanIgnoreReturnValue + Builder secondarySecretHash(String secondarySecretHash); + + @CanIgnoreReturnValue + Builder secondarySecretHash(Optional secondarySecretHash); + + @CanIgnoreReturnValue + Builder secretSalt(String secretSalt); + + @CanIgnoreReturnValue + Builder secretSalt(Optional secretSalt); + + @CanIgnoreReturnValue + Builder clientId(String clientId); + + @CanIgnoreReturnValue + Builder clientId(Optional clientId); + + @CanIgnoreReturnValue + Builder credentialRotationRequired(boolean credentialRotationRequired); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/principals/PrincipalRoleObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/principals/PrincipalRoleObj.java new file mode 100644 index 0000000000..6df45d1304 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/principals/PrincipalRoleObj.java @@ -0,0 +1,66 @@ +/* + * 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.polaris.persistence.nosql.coretypes.principals; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.ObjBase; +import org.immutables.value.Value; + +@PolarisImmutable +@JsonSerialize(as = ImmutablePrincipalRoleObj.class) +@JsonDeserialize(as = ImmutablePrincipalRoleObj.class) +public interface PrincipalRoleObj extends ObjBase { + ObjType TYPE = new PrincipalRoleObjType(); + + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + @Value.Default + default boolean federated() { + return false; + } + + @Override + default ObjType type() { + return TYPE; + } + + static Builder builder() { + return ImmutablePrincipalRoleObj.builder(); + } + + final class PrincipalRoleObjType extends AbstractObjType { + public PrincipalRoleObjType() { + super("pr-r", "Principal Role", PrincipalRoleObj.class); + } + } + + interface Builder extends ObjBase.Builder { + + @CanIgnoreReturnValue + Builder from(PrincipalRoleObj from); + + @CanIgnoreReturnValue + Builder federated(boolean federated); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/principals/PrincipalRolesObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/principals/PrincipalRolesObj.java new file mode 100644 index 0000000000..d94edfc614 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/principals/PrincipalRolesObj.java @@ -0,0 +1,68 @@ +/* + * 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.polaris.persistence.nosql.coretypes.principals; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; + +/** + * Maintains the mapping of all principal roles to {@link PrincipalRoleObj}s. The current version of + * this object is maintained via the reference {@value #PRINCIPAL_ROLES_REF_NAME}. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutablePrincipalRolesObj.class) +@JsonDeserialize(as = ImmutablePrincipalRolesObj.class) +public interface PrincipalRolesObj extends ContainerObj { + + String PRINCIPAL_ROLES_REF_NAME = "principal-roles"; + + ObjType TYPE = new PrincipalRolesObjType(); + + /** Mapping of principal role names to principal role objects. */ + @Override + IndexContainer nameToObjRef(); + + // overridden only for posterity, no technical reason + @Override + IndexContainer stableIdToName(); + + static ImmutablePrincipalRolesObj.Builder builder() { + return ImmutablePrincipalRolesObj.builder(); + } + + @Override + default ObjType type() { + return TYPE; + } + + final class PrincipalRolesObjType extends AbstractObjType { + public PrincipalRolesObjType() { + super("pr-rls", "Principal Roles", PrincipalRolesObj.class); + } + } + + interface Builder extends ContainerObj.Builder {} +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/principals/PrincipalsObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/principals/PrincipalsObj.java new file mode 100644 index 0000000000..57b0ec30ad --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/principals/PrincipalsObj.java @@ -0,0 +1,76 @@ +/* + * 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.polaris.persistence.nosql.coretypes.principals; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; + +/** + * Maintains the mapping of all principals by name to {@link PrincipalObj}s. The current version of + * this object is maintained via the reference {@value #PRINCIPALS_REF_NAME}. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutablePrincipalsObj.class) +@JsonDeserialize(as = ImmutablePrincipalsObj.class) +public interface PrincipalsObj extends ContainerObj { + + String PRINCIPALS_REF_NAME = "principals"; + + ObjType TYPE = new PrincipalsObjType(); + + /** Mapping of principal role names to principal objects. */ + @Override + IndexContainer nameToObjRef(); + + // overridden only for posterity, no technical reason + @Override + IndexContainer stableIdToName(); + + /** Mapping of principal client ID to principal objects. */ + IndexContainer byClientId(); + + static ImmutablePrincipalsObj.Builder builder() { + return ImmutablePrincipalsObj.builder(); + } + + @Override + default ObjType type() { + return TYPE; + } + + final class PrincipalsObjType extends AbstractObjType { + public PrincipalsObjType() { + super("prs", "Principals", PrincipalsObj.class); + } + } + + interface Builder extends ContainerObj.Builder { + + @CanIgnoreReturnValue + Builder byClientId(IndexContainer byClientId); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/ImmediateTaskObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/ImmediateTaskObj.java new file mode 100644 index 0000000000..bbc5c7433c --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/ImmediateTaskObj.java @@ -0,0 +1,97 @@ +/* + * 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.polaris.persistence.nosql.coretypes.realm; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.Optional; +import java.util.OptionalInt; +import org.apache.polaris.core.entity.AsyncTaskType; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.ObjBase; + +@PolarisImmutable +@JsonSerialize(as = ImmutableImmediateTaskObj.class) +@JsonDeserialize(as = ImmutableImmediateTaskObj.class) +public interface ImmediateTaskObj extends ObjBase { + + ObjType TYPE = new ImmediateTaskObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + Optional taskType(); + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional serializedEntity(); + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional lastAttemptExecutorId(); + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional lastAttemptStartTime(); + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + OptionalInt attemptCount(); + + static Builder builder() { + return ImmutableImmediateTaskObj.builder(); + } + + final class ImmediateTaskObjType extends AbstractObjType { + public ImmediateTaskObjType() { + super("tsk", "Task", ImmediateTaskObj.class); + } + } + + @SuppressWarnings("unused") + interface Builder extends ObjBase.Builder { + @CanIgnoreReturnValue + Builder from(ImmediateTaskObj from); + + @CanIgnoreReturnValue + Builder taskType(AsyncTaskType taskType); + + @CanIgnoreReturnValue + Builder taskType(Optional taskType); + + @CanIgnoreReturnValue + Builder serializedEntity(ByteBuffer serializedEntity); + + @CanIgnoreReturnValue + Builder serializedEntity(Optional serializedEntity); + + @CanIgnoreReturnValue + Builder lastAttemptExecutorId(String lastAttemptExecutorId); + + @CanIgnoreReturnValue + Builder lastAttemptStartTime(Instant lastAttemptStartTime); + + @CanIgnoreReturnValue + Builder attemptCount(int attemptCount); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/ImmediateTasksObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/ImmediateTasksObj.java new file mode 100644 index 0000000000..6ff22ddf6d --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/ImmediateTasksObj.java @@ -0,0 +1,68 @@ +/* + * 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.polaris.persistence.nosql.coretypes.realm; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.ContainerObj; + +/** + * Maintains all {@link ImmediateTaskObj}. The current version of this object is maintained via the + * reference {@value #IMMEDIATE_TASKS_REF_NAME}. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutableImmediateTasksObj.class) +@JsonDeserialize(as = ImmutableImmediateTasksObj.class) +public interface ImmediateTasksObj extends ContainerObj { + + String IMMEDIATE_TASKS_REF_NAME = "immediate-tasks"; + + ObjType TYPE = new ImmediateTasksObjType(); + + // overridden only for posterity, no technical reason + @Override + IndexContainer nameToObjRef(); + + // overridden only for posterity, no technical reason + @Override + IndexContainer stableIdToName(); + + static ImmutableImmediateTasksObj.Builder builder() { + return ImmutableImmediateTasksObj.builder(); + } + + @Override + default ObjType type() { + return TYPE; + } + + final class ImmediateTasksObjType extends AbstractObjType { + public ImmediateTasksObjType() { + super("itasks", "Immediate Tasks", ImmediateTasksObj.class); + } + } + + interface Builder extends ContainerObj.Builder {} +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/PolicyMapping.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/PolicyMapping.java new file mode 100644 index 0000000000..f63f065fb2 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/PolicyMapping.java @@ -0,0 +1,47 @@ +/* + * 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.polaris.persistence.nosql.coretypes.realm; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.util.Map; +import java.util.Optional; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; + +@PolarisImmutable +@JsonSerialize(as = ImmutablePolicyMapping.class) +@JsonDeserialize(as = ImmutablePolicyMapping.class) +public interface PolicyMapping { + @JsonInclude(JsonInclude.Include.NON_EMPTY) + Map parameters(); + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + Optional externalMapping(); + + static ImmutablePolicyMapping.Builder builder() { + return ImmutablePolicyMapping.builder(); + } + + PolicyMapping EMPTY = builder().parameters(Map.of()).build(); + + IndexValueSerializer POLICY_MAPPING_SERIALIZER = new PolicyMappingSerializer(); +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/PolicyMappingSerializer.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/PolicyMappingSerializer.java new file mode 100644 index 0000000000..7db99e4b28 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/PolicyMappingSerializer.java @@ -0,0 +1,114 @@ +/* + * 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.polaris.persistence.nosql.coretypes.realm; + +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.varint.VarInt.putVarInt; +import static org.apache.polaris.persistence.varint.VarInt.readVarInt; +import static org.apache.polaris.persistence.varint.VarInt.varIntLen; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.nio.ByteBuffer; +import java.util.Map; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; + +final class PolicyMappingSerializer implements IndexValueSerializer { + @Override + public int serializedSize(@Nullable PolicyMapping value) { + if (value == null) { + value = PolicyMapping.EMPTY; + } + + var len = 0; + + var params = value.parameters(); + len += varIntLen(params.size()); + for (Map.Entry e : params.entrySet()) { + len += IndexKey.key(e.getKey()).serializedSize(); + len += IndexKey.key(e.getValue()).serializedSize(); + } + + var ext = value.externalMapping(); + len++; + if (ext.isPresent()) { + len += OBJ_REF_SERIALIZER.serializedSize(ext.get()); + } + + return len; + } + + @Override + @Nonnull + public ByteBuffer serialize(@Nullable PolicyMapping value, @Nonnull ByteBuffer target) { + if (value == null) { + value = PolicyMapping.EMPTY; + } + + var params = value.parameters(); + putVarInt(target, params.size()); + params.forEach( + (k, v) -> { + IndexKey.key(k).serialize(target); + IndexKey.key(v).serialize(target); + }); + + var ext = value.externalMapping(); + if (ext.isPresent()) { + target.put((byte) 1); + OBJ_REF_SERIALIZER.serialize(ext.get(), target); + } else { + target.put((byte) 0); + } + + return target; + } + + @Override + public PolicyMapping deserialize(@Nonnull ByteBuffer buffer) { + var builder = PolicyMapping.builder(); + var num = readVarInt(buffer); + for (int i = 0; i < num; i++) { + var k = IndexKey.deserializeKey(buffer).toString(); + var v = IndexKey.deserializeKey(buffer).toString(); + builder.putParameter(k, v); + } + + if (buffer.get() == 1) { + builder.externalMapping(OBJ_REF_SERIALIZER.deserialize(buffer)); + } + + return builder.build(); + } + + @Override + public void skip(@Nonnull ByteBuffer buffer) { + var num = readVarInt(buffer); + for (int i = 0; i < num; i++) { + IndexKey.skip(buffer); + IndexKey.skip(buffer); + } + + if (buffer.get() == 1) { + OBJ_REF_SERIALIZER.skip(buffer); + } + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/PolicyMappingsObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/PolicyMappingsObj.java new file mode 100644 index 0000000000..9ec4c1ddb1 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/PolicyMappingsObj.java @@ -0,0 +1,208 @@ +/* + * 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.polaris.persistence.nosql.coretypes.realm; + +import static org.apache.polaris.persistence.varint.VarInt.putVarInt; +import static org.apache.polaris.persistence.varint.VarInt.readVarInt; +import static org.apache.polaris.persistence.varint.VarInt.readVarLong; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.nio.ByteBuffer; +import org.apache.polaris.core.policy.PolarisPolicyMappingRecord; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.BaseCommitObj; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; + +@PolarisImmutable +@JsonSerialize(as = ImmutablePolicyMappingsObj.class) +@JsonDeserialize(as = ImmutablePolicyMappingsObj.class) +public interface PolicyMappingsObj extends BaseCommitObj { + + String POLICY_MAPPINGS_REF_NAME = "policy-mappings"; + + ObjType TYPE = new PolicyMappingsObjType(); + + IndexContainer policyMappings(); + + @Override + default ObjType type() { + return TYPE; + } + + static ImmutablePolicyMappingsObj.Builder builder() { + return ImmutablePolicyMappingsObj.builder(); + } + + final class PolicyMappingsObjType extends AbstractObjType { + public PolicyMappingsObjType() { + super("polmap", "PolicyMappings", PolicyMappingsObj.class); + } + } + + interface Builder extends BaseCommitObj.Builder { + @CanIgnoreReturnValue + Builder from(PolicyMappingsObj container); + + @CanIgnoreReturnValue + Builder policyMappings(IndexContainer policyMappings); + } + + // - key by entity: + // - E/entityCatalogId/entityId/policyType/policyCatalogId/policyId + // VALUE: PolicyMapping(parameters) + // - key by policy: + // - P/policyType/policyCatalogId/policyId/entityCatalogId/entityId + // VALUE: (empty) + + interface PolicyMappingKey { + static PolicyMappingKey fromIndexKey(IndexKey key) { + var buffer = key.asByteBuffer(); + var type = buffer.get(); + return switch (type) { + case 'E' -> KeyByEntity.fromBuffer(buffer); + case 'P' -> KeyByPolicy.fromBuffer(buffer); + default -> + throw new IllegalArgumentException("Invalid policy mapping key type: " + (char) type); + }; + } + + PolicyMappingKey reverse(); + + IndexKey toIndexKey(); + + PolarisPolicyMappingRecord toMappingRecord(PolicyMapping value); + } + + record KeyByEntity( + long entityCatalogId, long entityId, int policyType, long policyCatalogId, long policyId) + implements PolicyMappingKey { + static KeyByEntity fromBuffer(ByteBuffer buffer) { + var entityCatalogId = readVarLong(buffer); + var entityId = readVarLong(buffer); + var policyType = readVarInt(buffer); + var policyCatalogId = readVarLong(buffer); + var policyId = readVarLong(buffer); + return new KeyByEntity(entityCatalogId, entityId, policyType, policyCatalogId, policyId); + } + + @Override + public PolicyMappingKey reverse() { + return new KeyByPolicy(policyCatalogId, policyId, policyType, entityCatalogId, entityId); + } + + @Override + public IndexKey toIndexKey() { + var buffer = ByteBuffer.allocate(1 + 5 * 9); + buffer.put((byte) 'E'); + putVarInt(buffer, entityCatalogId); + putVarInt(buffer, entityId); + putVarInt(buffer, policyType); + putVarInt(buffer, policyCatalogId); + putVarInt(buffer, policyId); + buffer.flip(); + return IndexKey.key(buffer); + } + + public IndexKey toEntityPartialIndexKey() { + var buffer = ByteBuffer.allocate(1 + 2 * 9); + buffer.put((byte) 'E'); + putVarInt(buffer, entityCatalogId); + putVarInt(buffer, entityId); + buffer.flip(); + return IndexKey.key(buffer); + } + + public IndexKey toPolicyTypePartialIndexKey() { + var buffer = ByteBuffer.allocate(1 + 3 * 9); + buffer.put((byte) 'E'); + putVarInt(buffer, entityCatalogId); + putVarInt(buffer, entityId); + putVarInt(buffer, policyType); + buffer.flip(); + return IndexKey.key(buffer); + } + + @Override + public PolarisPolicyMappingRecord toMappingRecord(PolicyMapping value) { + return new PolarisPolicyMappingRecord( + entityCatalogId, entityId, policyCatalogId, policyId, policyType, value.parameters()); + } + } + + record KeyByPolicy( + long policyCatalogId, long policyId, int policyType, long entityCatalogId, long entityId) + implements PolicyMappingKey { + static KeyByPolicy fromBuffer(ByteBuffer buffer) { + var policyCatalogId = readVarLong(buffer); + var policyId = readVarLong(buffer); + var policyType = readVarInt(buffer); + var entityCatalogId = readVarLong(buffer); + var entityId = readVarLong(buffer); + return new KeyByPolicy(policyCatalogId, policyId, policyType, entityCatalogId, entityId); + } + + @Override + public PolicyMappingKey reverse() { + return new KeyByEntity(entityCatalogId, entityId, policyType, policyCatalogId, policyId); + } + + @Override + public IndexKey toIndexKey() { + var buffer = ByteBuffer.allocate(1 + 5 * 9); + buffer.put((byte) 'P'); + putVarInt(buffer, policyCatalogId); + putVarInt(buffer, policyId); + putVarInt(buffer, policyType); + putVarInt(buffer, entityCatalogId); + putVarInt(buffer, entityId); + buffer.flip(); + return IndexKey.key(buffer); + } + + public IndexKey toPolicyPartialIndexKey() { + var buffer = ByteBuffer.allocate(1 + 2 * 9); + buffer.put((byte) 'P'); + putVarInt(buffer, policyCatalogId); + putVarInt(buffer, policyId); + buffer.flip(); + return IndexKey.key(buffer); + } + + public IndexKey toPolicyWithTypePartialIndexKey() { + var buffer = ByteBuffer.allocate(1 + 3 * 9); + buffer.put((byte) 'P'); + putVarInt(buffer, policyCatalogId); + putVarInt(buffer, policyId); + putVarInt(buffer, policyType); + buffer.flip(); + return IndexKey.key(buffer); + } + + @Override + public PolarisPolicyMappingRecord toMappingRecord(PolicyMapping value) { + return new PolarisPolicyMappingRecord( + entityCatalogId, entityId, policyCatalogId, policyId, policyType, value.parameters()); + } + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/RealmGrantsObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/RealmGrantsObj.java new file mode 100644 index 0000000000..e951752f31 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/RealmGrantsObj.java @@ -0,0 +1,57 @@ +/* + * 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.polaris.persistence.nosql.coretypes.realm; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.acl.GrantsObj; + +/** + * Maintains the state of all realm grants. The current version of this object is maintained via the + * reference name {@value #REALM_GRANTS_REF_NAME}. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutableRealmGrantsObj.class) +@JsonDeserialize(as = ImmutableRealmGrantsObj.class) +public interface RealmGrantsObj extends GrantsObj { + + String REALM_GRANTS_REF_NAME = "grants"; + + ObjType TYPE = new RealmGrantsObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + static ImmutableRealmGrantsObj.Builder builder() { + return ImmutableRealmGrantsObj.builder(); + } + + final class RealmGrantsObjType extends AbstractObjType { + public RealmGrantsObjType() { + super("gts", "Realm Grants", RealmGrantsObj.class); + } + } + + interface Builder extends GrantsObj.Builder {} +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/RootObj.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/RootObj.java new file mode 100644 index 0000000000..f6016db57d --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/realm/RootObj.java @@ -0,0 +1,61 @@ +/* + * 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.polaris.persistence.nosql.coretypes.realm; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.BaseCommitObj; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.coretypes.ObjBase; + +// TODO this "root" is a singleton in the realm - is this really necessary? +@PolarisImmutable +@JsonSerialize(as = ImmutableRootObj.class) +@JsonDeserialize(as = ImmutableRootObj.class) +public interface RootObj extends BaseCommitObj, ObjBase { + + String ROOT_REF_NAME = "root"; + + ObjType TYPE = new RootObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + static Builder builder() { + return ImmutableRootObj.builder(); + } + + final class RootObjType extends AbstractObjType { + public RootObjType() { + super("root", "Root", RootObj.class); + } + } + + interface Builder + extends BaseCommitObj.Builder, ObjBase.Builder { + + @CanIgnoreReturnValue + Builder from(RootObj from); + } +} diff --git a/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/refs/References.java b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/refs/References.java new file mode 100644 index 0000000000..db4ff78dad --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/java/org/apache/polaris/persistence/nosql/coretypes/refs/References.java @@ -0,0 +1,68 @@ +/* + * 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.polaris.persistence.nosql.coretypes.refs; + +import static java.lang.String.format; + +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogGrantsObj; +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.principals.PrincipalRolesObj; +import org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalsObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.ImmediateTasksObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.PolicyMappingsObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.RealmGrantsObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.RootObj; + +public final class References { + private References() {} + + private static final Set REALM_REFERENCE_NAMES = + Set.of( + RootObj.ROOT_REF_NAME, + CatalogsObj.CATALOGS_REF_NAME, + PrincipalsObj.PRINCIPALS_REF_NAME, + PrincipalRolesObj.PRINCIPAL_ROLES_REF_NAME, + RealmGrantsObj.REALM_GRANTS_REF_NAME, + ImmediateTasksObj.IMMEDIATE_TASKS_REF_NAME, + PolicyMappingsObj.POLICY_MAPPINGS_REF_NAME); + + private static final Set CATALOG_REFERENCE_PATTERNS = + Set.of( + CatalogRolesObj.CATALOG_ROLES_REF_NAME_PATTERN, + CatalogStateObj.CATALOG_STATE_REF_NAME_PATTERN, + CatalogGrantsObj.CATALOG_GRANTS_REF_NAME_PATTERN); + + public static Set realmReferenceNames() { + return REALM_REFERENCE_NAMES; + } + + public static Set catalogReferenceNames(long catalogStableId) { + return CATALOG_REFERENCE_PATTERNS.stream() + .map(refNamePattern -> perCatalogReferenceName(refNamePattern, catalogStableId)) + .collect(Collectors.toSet()); + } + + public static String perCatalogReferenceName(String refNamePattern, long catalogStableId) { + return format(refNamePattern, catalogStableId); + } +} diff --git a/persistence/nosql/persistence/types/src/main/resources/META-INF/beans.xml b/persistence/nosql/persistence/types/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/persistence/types/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType b/persistence/nosql/persistence/types/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType new file mode 100644 index 0000000000..9fef0533f8 --- /dev/null +++ b/persistence/nosql/persistence/types/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType @@ -0,0 +1,45 @@ +# +# 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. +# + +org.apache.polaris.persistence.nosql.coretypes.acl.AclObj$AclObjType + +org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogObj$CatalogObjType +org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogRoleObj$CatalogRoleObjType +org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogRolesObj$CatalogRolesObjType +org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogStateObj$CatalogStatesObjType +org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogsObj$CatalogsObjType +org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogGrantsObj$CatalogGrantsObjType + +org.apache.polaris.persistence.nosql.coretypes.content.IcebergTableObj$IcebergTableObjType +org.apache.polaris.persistence.nosql.coretypes.content.IcebergViewObj$IcebergViewObjType +org.apache.polaris.persistence.nosql.coretypes.content.GenericTableObj$GenericTableObjType +org.apache.polaris.persistence.nosql.coretypes.content.LocalNamespaceObj$LocalNamespaceObjType +org.apache.polaris.persistence.nosql.coretypes.content.RemoteNamespaceObj$RemoteNamespaceObjType +org.apache.polaris.persistence.nosql.coretypes.content.PolicyObj$CatalogPolicyObjType + +org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalObj$PrincipalObjType +org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalRoleObj$PrincipalRoleObjType +org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalRolesObj$PrincipalRolesObjType +org.apache.polaris.persistence.nosql.coretypes.principals.PrincipalsObj$PrincipalsObjType + +org.apache.polaris.persistence.nosql.coretypes.realm.RootObj$RootObjType +org.apache.polaris.persistence.nosql.coretypes.realm.ImmediateTaskObj$ImmediateTaskObjType +org.apache.polaris.persistence.nosql.coretypes.realm.ImmediateTasksObj$ImmediateTasksObjType +org.apache.polaris.persistence.nosql.coretypes.realm.RealmGrantsObj$RealmGrantsObjType +org.apache.polaris.persistence.nosql.coretypes.realm.PolicyMappingsObj$PolicyMappingsObjType diff --git a/persistence/nosql/persistence/types/src/test/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/TestCatalogMaintenance.java b/persistence/nosql/persistence/types/src/test/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/TestCatalogMaintenance.java new file mode 100644 index 0000000000..906ab032e7 --- /dev/null +++ b/persistence/nosql/persistence/types/src/test/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/TestCatalogMaintenance.java @@ -0,0 +1,493 @@ +/* + * 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.polaris.persistence.nosql.coretypes.maintenance; + +import static java.util.function.Function.identity; +import static org.apache.polaris.persistence.nosql.api.index.IndexContainer.newUpdatableIndex; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.INDEX_KEY_SERIALIZER; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.apache.polaris.persistence.nosql.coretypes.catalog.LongValues.LONG_VALUES_SERIALIZER; +import static org.apache.polaris.persistence.nosql.coretypes.changes.Change.CHANGE_SERIALIZER; +import static org.apache.polaris.persistence.nosql.coretypes.realm.PolicyMapping.POLICY_MAPPING_SERIALIZER; +import static org.apache.polaris.persistence.nosql.coretypes.refs.References.realmReferenceNames; +import static org.apache.polaris.persistence.nosql.maintenance.impl.MutableMaintenanceConfig.GRACE_TIME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.fail; + +import io.smallrye.common.annotation.Identifier; +import jakarta.inject.Inject; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.polaris.core.PolarisCallContext; +import org.apache.polaris.core.config.PolarisConfigurationStore; +import org.apache.polaris.core.context.RealmContext; +import org.apache.polaris.core.entity.CatalogEntity; +import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.core.entity.PolarisEntity; +import org.apache.polaris.core.entity.PolarisEntitySubType; +import org.apache.polaris.core.entity.PolarisEntityType; +import org.apache.polaris.core.entity.PrincipalEntity; +import org.apache.polaris.core.persistence.MetaStoreManagerFactory; +import org.apache.polaris.core.persistence.PolarisMetaStoreManager; +import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet; +import org.apache.polaris.ids.mocks.MutableMonotonicClock; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.RealmPersistenceFactory; +import org.apache.polaris.persistence.nosql.api.cache.CacheBackend; +import org.apache.polaris.persistence.nosql.api.ref.Reference; +import org.apache.polaris.persistence.nosql.coretypes.catalog.CatalogGrantsObj; +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.realm.ImmediateTasksObj; +import org.apache.polaris.persistence.nosql.coretypes.realm.PolicyMappingsObj; +import org.apache.polaris.persistence.nosql.coretypes.refs.References; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceConfig; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceRunInformation; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceRunSpec; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceService; +import org.apache.polaris.persistence.nosql.maintenance.impl.MutableMaintenanceConfig; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.jboss.weld.junit5.EnableWeld; +import org.jboss.weld.junit5.WeldInitiator; +import org.jboss.weld.junit5.WeldSetup; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +@EnableWeld +@SuppressWarnings("CdiInjectionPointsInspection") +public class TestCatalogMaintenance { + @InjectSoftAssertions protected SoftAssertions soft; + + @WeldSetup WeldInitiator weld = WeldInitiator.performDefaultDiscovery(); + + String realmId; + RealmContext realmContext; + + @Inject MaintenanceService maintenance; + @Inject MutableMonotonicClock mutableMonotonicClock; + + @Inject PolarisConfigurationStore configurationStore; + @Inject CacheBackend cacheBackend; + @Inject RealmPersistenceFactory realmPersistenceFactory; + + @Inject + @Identifier("nosql") + MetaStoreManagerFactory metaStoreManagerFactory; + + @BeforeEach + protected void setup() { + // Set the "grace time" to 0 so tests can write refs+objs and get those purged + MutableMaintenanceConfig.setCurrent( + MaintenanceConfig.builder().createdAtGraceTime(GRACE_TIME).build()); + + realmId = UUID.randomUUID().toString(); + realmContext = () -> realmId; + + // tell maintenance to only retain the latest commit + MutableCatalogsMaintenanceConfig.setCurrent( + CatalogsMaintenanceConfig.BuildableCatalogsMaintenanceConfig.builder() + .catalogRolesRetain("false") + .catalogsHistoryRetain("false") + .catalogPoliciesRetain("false") + .catalogStateRetain("false") + .grantsRetain("false") + .principalRolesRetain("false") + .principalsRetain("false") + .immediateTasksRetain("false") + .build()); + } + + @Test + public void catalogMaintenance() { + + metaStoreManagerFactory.bootstrapRealms(List.of(realmId), RootCredentialsSet.fromEnvironment()); + + var manager = metaStoreManagerFactory.getOrCreateMetaStoreManager(realmContext); + var session = metaStoreManagerFactory.getOrCreateSession(realmContext); + var callCtx = new PolarisCallContext(realmContext, session, configurationStore); + + var persistence = + realmPersistenceFactory.newBuilder().realmId(realmId).skipDecorators().build(); + + // Some references are "empty", need to populate those to be able to bump the references "back" + // to the "real" state below. + mandatoryRealmObjsForTestImpl(persistence); + + var initialReferenceHeads = new HashMap(); + realmReferenceNames().forEach(n -> initialReferenceHeads.put(n, persistence.fetchReference(n))); + + var principalRole = createPrincipalRole(manager, callCtx, persistence); + var principal = createPrincipal(manager, callCtx, persistence); + + var catalog = createCatalog(manager, callCtx, persistence); + var catalogBase = new PolarisBaseEntity.Builder(catalog).build(); + var catalogId = catalog.getId(); + + // Some references are "empty", need to populate those to be able to bump the references "back" + // to the "real" state below. + mandatoryCatalogObjsForTestImpl(persistence, catalogId); + + References.catalogReferenceNames(catalogId) + .forEach(n -> initialReferenceHeads.put(n, persistence.fetchReference(n))); + + var catalogRole = createCatalogRole(manager, callCtx, catalog, persistence); + var namespace = createNamespace(manager, callCtx, catalog, persistence); + var table = createTable(manager, callCtx, catalog, namespace, persistence); + + var entities = List.of(principalRole, principal, catalogBase, catalogRole, namespace, table); + + checkEntities("sanity", entities); + + // Ensure that "maintenance does not purge objects created before references are bumped". + // In other words: maintenance runs during a commit operation - those objects are protected by + // the "grace period". + + // Update the references to the initial state, so the created objects become "unreachable" - the + // state before the commits' reference bumps. + var currentReferenceHeads = + Stream.concat( + realmReferenceNames().stream(), + References.catalogReferenceNames(catalogId).stream()) + .toList() + .stream() + .collect(Collectors.toMap(identity(), persistence::fetchReference)); + currentReferenceHeads.forEach( + (n, r) -> { + var initial = initialReferenceHeads.get(r.name()).pointer().orElseThrow(); + if (!initial.equals(r.pointer().orElseThrow())) { + assertThat(persistence.updateReferencePointer(r, initial)).describedAs(n).isPresent(); + } + }); + + var runInformation = + maintenance.performMaintenance( + MaintenanceRunSpec.builder() + .includeSystemRealm(false) + .realmsToProcess(Set.of(realmId)) + .build()); + soft.assertThat(runInformation) + .describedAs("%s", runInformation) + .extracting( + MaintenanceRunInformation::success, + MaintenanceRunInformation::purgedRealms, + ri -> ri.referenceStats().map(s -> s.purged().orElse(-1L)), + ri -> ri.objStats().map(s -> s.purged().orElse(-1L))) + .containsExactly( + true, + OptionalInt.of(0), + Optional.of(0L), + // Within grace-time -> nothing must be purged + Optional.of(0L)); + + // Revert the references to the "real" state + initialReferenceHeads.forEach( + (n, r) -> { + var real = currentReferenceHeads.get(r.name()).pointer().orElseThrow(); + if (!real.equals(r.pointer().orElseThrow())) { + assertThat(persistence.updateReferencePointer(r, real)).describedAs(n).isPresent(); + } + }); + + checkEntities("real state within grace", entities); + + // Perform a maintenance run _after_ the references have been bumped (successful commits). + + mutableMonotonicClock.advanceBoth(GRACE_TIME); + runInformation = + maintenance.performMaintenance( + MaintenanceRunSpec.builder() + .includeSystemRealm(false) + .realmsToProcess(Set.of(realmId)) + .build()); + soft.assertThat(runInformation) + .describedAs("%s", runInformation) + .extracting( + MaintenanceRunInformation::success, + MaintenanceRunInformation::purgedRealms, + ri -> ri.referenceStats().map(s -> s.purged().orElse(-1L)), + ri -> ri.objStats().map(s -> s.purged().orElse(-1L))) + .containsExactly( + true, + OptionalInt.of(0), + Optional.of(0L), + // 8 stale objects: + // - 1 namespace (catalog state) + // - 1 table (catalog state) + // - 1 grants (realm setup) - including 1 ACLs + // - 1 principal + // - 1 principal role + // - 1 catalog role + // - 1 catalog + Optional.of(8L)); + + checkEntities("real state after grace", entities); + } + + private static PolarisBaseEntity createTable( + PolarisMetaStoreManager manager, + PolarisCallContext callCtx, + PolarisBaseEntity catalog, + PolarisBaseEntity namespace, + Persistence persistence) { + var tableResult = + manager.createEntityIfNotExists( + callCtx, + List.of(catalog, namespace), + new PolarisEntity.Builder() + .setType(PolarisEntityType.TABLE_LIKE) + .setSubType(PolarisEntitySubType.ICEBERG_TABLE) + .setName("table1") + .setId(persistence.generateId()) + .setCatalogId(catalog.getId()) + .setCreateTimestamp(System.currentTimeMillis()) + .build()); + return tableResult.getEntity(); + } + + private static PolarisBaseEntity createNamespace( + PolarisMetaStoreManager manager, + PolarisCallContext callCtx, + PolarisBaseEntity catalog, + Persistence persistence) { + var namespaceResult = + manager.createEntityIfNotExists( + callCtx, + List.of(catalog), + new PolarisEntity.Builder() + .setType(PolarisEntityType.NAMESPACE) + .setName("ns") + .setId(persistence.generateId()) + .setCatalogId(catalog.getId()) + .setCreateTimestamp(System.currentTimeMillis()) + .build()); + return namespaceResult.getEntity(); + } + + private static PolarisBaseEntity createCatalogRole( + PolarisMetaStoreManager manager, + PolarisCallContext callCtx, + PolarisBaseEntity catalog, + Persistence persistence) { + var catalogRoleResult = + manager.createEntityIfNotExists( + callCtx, + List.of(catalog), + new PolarisEntity.Builder() + .setType(PolarisEntityType.CATALOG_ROLE) + .setName("catalog-role") + .setId(persistence.generateId()) + .setCatalogId(catalog.getId()) + .setCreateTimestamp(System.currentTimeMillis()) + .build()); + return catalogRoleResult.getEntity(); + } + + private static PolarisBaseEntity createCatalog( + PolarisMetaStoreManager manager, PolarisCallContext callCtx, Persistence persistence) { + var catalogResult = + manager.createCatalog( + callCtx, + new PolarisEntity.Builder( + new CatalogEntity.Builder() + .setName("catalog") + .setDefaultBaseLocation("file:///tmp/foo/bar/baz") + .setCatalogType("INTERNAL") + .build()) + .setId(persistence.generateId()) + .setCatalogId(0L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(), + List.of()); + return catalogResult.getCatalog(); + } + + private static PolarisBaseEntity createPrincipal( + PolarisMetaStoreManager manager, PolarisCallContext callCtx, Persistence persistence) { + var principalResult = + manager.createPrincipal( + callCtx, + new PrincipalEntity.Builder() + .setType(PolarisEntityType.PRINCIPAL) + .setName("principal") + .setId(persistence.generateId()) + .setCatalogId(0L) + .setCreateTimestamp(System.currentTimeMillis()) + .build()); + return principalResult.getPrincipal(); + } + + private static PolarisBaseEntity createPrincipalRole( + PolarisMetaStoreManager manager, PolarisCallContext callCtx, Persistence persistence) { + var principalRoleResult = + manager.createEntityIfNotExists( + callCtx, + List.of(), + new PolarisEntity.Builder() + .setType(PolarisEntityType.PRINCIPAL_ROLE) + .setName("principal-role") + .setId(persistence.generateId()) + .setCatalogId(0L) + .setCreateTimestamp(System.currentTimeMillis()) + .build()); + return principalRoleResult.getEntity(); + } + + private static void mandatoryCatalogObjsForTestImpl(Persistence persistence, long catalogId) { + var catalogStateObj = + persistence.write( + CatalogStateObj.builder() + .id(persistence.generateId()) + .stableIdToName( + newUpdatableIndex(persistence, INDEX_KEY_SERIALIZER) + .toIndexed("", (x, o) -> fail())) + .nameToObjRef( + newUpdatableIndex(persistence, OBJ_REF_SERIALIZER) + .toIndexed("", (x, o) -> fail())) + .changes( + newUpdatableIndex(persistence, CHANGE_SERIALIZER) + .toIndexed("", (x, o) -> fail())) + .locations( + newUpdatableIndex(persistence, LONG_VALUES_SERIALIZER) + .toIndexed("", (x, o) -> fail())) + .createdAtMicros(persistence.currentTimeMicros()) + .seq(1) + .tail() + .build(), + CatalogStateObj.class); + persistence.updateReferencePointer( + persistence.fetchReference( + References.perCatalogReferenceName( + CatalogStateObj.CATALOG_STATE_REF_NAME_PATTERN, catalogId)), + objRef(catalogStateObj)); + + var catalogGrantsObj = + persistence.write( + CatalogGrantsObj.builder() + .id(persistence.generateId()) + .acls( + newUpdatableIndex(persistence, OBJ_REF_SERIALIZER) + .toIndexed("", (x, o) -> fail())) + .createdAtMicros(persistence.currentTimeMicros()) + .seq(1) + .tail() + .build(), + CatalogGrantsObj.class); + persistence.updateReferencePointer( + persistence.fetchReference( + References.perCatalogReferenceName( + CatalogGrantsObj.CATALOG_GRANTS_REF_NAME_PATTERN, catalogId)), + objRef(catalogGrantsObj)); + } + + private static void mandatoryRealmObjsForTestImpl(Persistence persistence) { + var catalogsObj = + persistence.write( + CatalogsObj.builder() + .id(persistence.generateId()) + .stableIdToName( + newUpdatableIndex(persistence, INDEX_KEY_SERIALIZER) + .toIndexed("", (x, o) -> fail())) + .nameToObjRef( + newUpdatableIndex(persistence, OBJ_REF_SERIALIZER) + .toIndexed("", (x, o) -> fail())) + .createdAtMicros(persistence.currentTimeMicros()) + .seq(1) + .tail() + .build(), + CatalogsObj.class); + persistence.updateReferencePointer( + persistence.fetchReference(CatalogsObj.CATALOGS_REF_NAME), objRef(catalogsObj)); + + var immediateTasksObj = + persistence.write( + ImmediateTasksObj.builder() + .id(persistence.generateId()) + .stableIdToName( + newUpdatableIndex(persistence, INDEX_KEY_SERIALIZER) + .toIndexed("", (x, o) -> fail())) + .nameToObjRef( + newUpdatableIndex(persistence, OBJ_REF_SERIALIZER) + .toIndexed("", (x, o) -> fail())) + .createdAtMicros(persistence.currentTimeMicros()) + .seq(1) + .tail() + .build(), + ImmediateTasksObj.class); + persistence.updateReferencePointer( + persistence.fetchReference(ImmediateTasksObj.IMMEDIATE_TASKS_REF_NAME), + objRef(immediateTasksObj)); + + var policyMappingsObj = + persistence.write( + PolicyMappingsObj.builder() + .id(persistence.generateId()) + .policyMappings( + newUpdatableIndex(persistence, POLICY_MAPPING_SERIALIZER) + .toIndexed("", (x, o) -> fail())) + .createdAtMicros(persistence.currentTimeMicros()) + .seq(1) + .tail() + .build(), + PolicyMappingsObj.class); + persistence.updateReferencePointer( + persistence.fetchReference(PolicyMappingsObj.POLICY_MAPPINGS_REF_NAME), + objRef(policyMappingsObj)); + } + + private void checkEntities(String step, List entities) { + // Purge the whole cache in case maintenance purged objects/references that should not have + // been purged to make the assertions catch those cases. + cacheBackend.purge(); + soft.assertThat(cacheBackend.estimatedSize()).describedAs(step).isEqualTo(0L); + + var manager = metaStoreManagerFactory.getOrCreateMetaStoreManager(realmContext); + var session = metaStoreManagerFactory.getOrCreateSession(realmContext); + var callCtx = new PolarisCallContext(realmContext, session, configurationStore); + + for (var e : entities) { + var result = + manager.loadResolvedEntityById(callCtx, e.getCatalogId(), e.getId(), e.getType()); + var loadedEntity = result.getEntity(); + soft.assertThat(loadedEntity) + .describedAs("%s: %s", step, result.getReturnStatus()) + .isEqualTo(e); + } + + for (var e : entities) { + var result = + manager.loadResolvedEntityByName( + callCtx, e.getCatalogId(), e.getParentId(), e.getType(), e.getName()); + var loadedEntity = result.getEntity(); + soft.assertThat(loadedEntity) + .describedAs("%s: %s", step, result.getReturnStatus()) + .isEqualTo(e); + } + } +} diff --git a/persistence/nosql/persistence/types/src/test/java/org/apache/polaris/persistence/nosql/coretypes/realm/TestPolicyMapping.java b/persistence/nosql/persistence/types/src/test/java/org/apache/polaris/persistence/nosql/coretypes/realm/TestPolicyMapping.java new file mode 100644 index 0000000000..2e68185597 --- /dev/null +++ b/persistence/nosql/persistence/types/src/test/java/org/apache/polaris/persistence/nosql/coretypes/realm/TestPolicyMapping.java @@ -0,0 +1,82 @@ +/* + * 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.polaris.persistence.nosql.coretypes.realm; + +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestPolicyMapping { + @InjectSoftAssertions protected SoftAssertions soft; + + @ParameterizedTest + @MethodSource + public void policyMappingSerialization(PolicyMapping mapping) { + var serSize = PolicyMapping.POLICY_MAPPING_SERIALIZER.serializedSize(mapping); + var buffer = ByteBuffer.allocate(serSize + 10); + PolicyMapping.POLICY_MAPPING_SERIALIZER.serialize(mapping, buffer); + soft.assertThat(buffer) + .extracting(ByteBuffer::position, ByteBuffer::remaining) + .containsExactly(serSize, 10); + buffer.put(new byte[10]); + soft.assertThat(buffer) + .extracting(ByteBuffer::position, ByteBuffer::remaining) + .containsExactly(serSize + 10, 0); + + buffer.flip(); + + soft.assertThat(buffer) + .extracting(ByteBuffer::position, ByteBuffer::remaining) + .containsExactly(0, serSize + 10); + + var skip = buffer.duplicate(); + PolicyMapping.POLICY_MAPPING_SERIALIZER.skip(skip); + soft.assertThat(skip) + .extracting(ByteBuffer::position, ByteBuffer::remaining) + .containsExactly(serSize, 10); + + var deser = buffer.duplicate(); + var deserialized = PolicyMapping.POLICY_MAPPING_SERIALIZER.deserialize(deser); + soft.assertThat(deser) + .extracting(ByteBuffer::position, ByteBuffer::remaining) + .containsExactly(serSize, 10); + soft.assertThat(deserialized).isEqualTo(mapping); + } + + static Stream policyMappingSerialization() { + return Stream.of( + PolicyMapping.EMPTY, + PolicyMapping.builder().parameters(Map.of("a", "b")).build(), + PolicyMapping.builder() + .parameters( + IntStream.range(0, 50) + .boxed() + .collect(Collectors.toMap(v -> "k" + v, v -> "v" + v))) + .build()); + } +} diff --git a/persistence/nosql/persistence/types/src/test/resources/logback-test.xml b/persistence/nosql/persistence/types/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..1de5ba06ea --- /dev/null +++ b/persistence/nosql/persistence/types/src/test/resources/logback-test.xml @@ -0,0 +1,34 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/persistence/nosql/persistence/types/src/test/resources/weld.properties b/persistence/nosql/persistence/types/src/test/resources/weld.properties new file mode 100644 index 0000000000..c26169e0e1 --- /dev/null +++ b/persistence/nosql/persistence/types/src/test/resources/weld.properties @@ -0,0 +1,21 @@ +# +# 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. +# + +# See https://bugs.openjdk.org/browse/JDK-8349545 +org.jboss.weld.bootstrap.concurrentDeployment=false diff --git a/persistence/nosql/persistence/types/src/testFixtures/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/CdiProducers.java b/persistence/nosql/persistence/types/src/testFixtures/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/CdiProducers.java new file mode 100644 index 0000000000..7e3034247d --- /dev/null +++ b/persistence/nosql/persistence/types/src/testFixtures/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/CdiProducers.java @@ -0,0 +1,67 @@ +/* + * 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.polaris.persistence.nosql.coretypes.maintenance; + +import jakarta.annotation.Nullable; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import java.time.Clock; +import org.apache.polaris.core.PolarisDefaultDiagServiceImpl; +import org.apache.polaris.core.PolarisDiagnostics; +import org.apache.polaris.core.config.PolarisConfigurationStore; +import org.apache.polaris.core.storage.PolarisStorageConfigurationInfo; +import org.apache.polaris.core.storage.PolarisStorageIntegration; +import org.apache.polaris.core.storage.PolarisStorageIntegrationProvider; + +@ApplicationScoped +public class CdiProducers { + public static MutableCatalogsMaintenanceConfig config = new MutableCatalogsMaintenanceConfig(); + + @Produces + MutableCatalogsMaintenanceConfig produceMutableCatalogsMaintenanceConfig() { + return config; + } + + @Produces + PolarisStorageIntegrationProvider producePolarisStorageIntegrationProvider() { + return new PolarisStorageIntegrationProvider() { + @Override + public @Nullable + PolarisStorageIntegration getStorageIntegrationForConfig( + PolarisStorageConfigurationInfo polarisStorageConfigurationInfo) { + throw new UnsupportedOperationException(); + } + }; + } + + @Produces + PolarisConfigurationStore producePolarisConfigurationStore() { + return new PolarisConfigurationStore() {}; + } + + @Produces + PolarisDiagnostics producePolarisDiagnostics() { + return new PolarisDefaultDiagServiceImpl(); + } + + @Produces + Clock produceClock() { + return Clock.systemUTC(); + } +} diff --git a/persistence/nosql/persistence/types/src/testFixtures/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/MutableCatalogsMaintenanceConfig.java b/persistence/nosql/persistence/types/src/testFixtures/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/MutableCatalogsMaintenanceConfig.java new file mode 100644 index 0000000000..b260d0f2b0 --- /dev/null +++ b/persistence/nosql/persistence/types/src/testFixtures/java/org/apache/polaris/persistence/nosql/coretypes/maintenance/MutableCatalogsMaintenanceConfig.java @@ -0,0 +1,71 @@ +/* + * 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.polaris.persistence.nosql.coretypes.maintenance; + +import java.util.Optional; + +public class MutableCatalogsMaintenanceConfig implements CatalogsMaintenanceConfig { + + private static CatalogsMaintenanceConfig current = + CatalogsMaintenanceConfig.BuildableCatalogsMaintenanceConfig.builder().build(); + + public static void setCurrent(CatalogsMaintenanceConfig config) { + current = config; + } + + @Override + public Optional principalsRetain() { + return current.principalsRetain(); + } + + @Override + public Optional principalRolesRetain() { + return current.principalRolesRetain(); + } + + @Override + public Optional grantsRetain() { + return current.grantsRetain(); + } + + @Override + public Optional immediateTasksRetain() { + return current.immediateTasksRetain(); + } + + @Override + public Optional catalogsHistoryRetain() { + return current.catalogsHistoryRetain(); + } + + @Override + public Optional catalogRolesRetain() { + return current.catalogRolesRetain(); + } + + @Override + public Optional catalogStateRetain() { + return current.catalogStateRetain(); + } + + @Override + public Optional catalogPoliciesRetain() { + return current.catalogPoliciesRetain(); + } +} diff --git a/persistence/nosql/persistence/types/src/testFixtures/resources/META-INF/beans.xml b/persistence/nosql/persistence/types/src/testFixtures/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/persistence/types/src/testFixtures/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/realms/store-nosql/build.gradle.kts b/persistence/nosql/realms/store-nosql/build.gradle.kts new file mode 100644 index 0000000000..744787fbdb --- /dev/null +++ b/persistence/nosql/realms/store-nosql/build.gradle.kts @@ -0,0 +1,66 @@ +/* + * 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris realms NoSQL persistence" + +dependencies { + implementation(project(":polaris-persistence-nosql-realms-api")) + implementation(project(":polaris-persistence-nosql-realms-spi")) + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-persistence-nosql-maintenance-spi")) + + implementation(libs.guava) + implementation(libs.slf4j.api) + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-annotations") + implementation("com.fasterxml.jackson.core:jackson-core") + implementation("com.fasterxml.jackson.core:jackson-databind") + runtimeOnly("com.fasterxml.jackson.datatype:jackson-datatype-guava") + runtimeOnly("com.fasterxml.jackson.datatype:jackson-datatype-jdk8") + runtimeOnly("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + testFixturesRuntimeOnly(project(":polaris-persistence-nosql-cdi-weld")) + testFixturesApi(testFixtures(project(":polaris-persistence-nosql-cdi-weld"))) + + testFixturesApi(libs.weld.se.core) + testFixturesApi(libs.weld.junit5) + testFixturesRuntimeOnly(libs.smallrye.jandex) + + testImplementation(libs.mockito.core) + testImplementation(libs.junit.pioneer) + + testCompileOnly(libs.jakarta.annotation.api) + testCompileOnly(libs.jakarta.validation.api) + testCompileOnly(libs.jakarta.inject.api) + testCompileOnly(libs.jakarta.enterprise.cdi.api) +} diff --git a/persistence/nosql/realms/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/realms/store/RealmManagementRetainedIdentifier.java b/persistence/nosql/realms/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/realms/store/RealmManagementRetainedIdentifier.java new file mode 100644 index 0000000000..0eaa597865 --- /dev/null +++ b/persistence/nosql/realms/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/realms/store/RealmManagementRetainedIdentifier.java @@ -0,0 +1,66 @@ +/* + * 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.polaris.persistence.nosql.realms.store; + +import static org.apache.polaris.persistence.nosql.realms.store.RealmsStateObj.REALMS_REF_NAME; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import org.apache.polaris.persistence.nosql.api.exceptions.ReferenceNotFoundException; +import org.apache.polaris.persistence.nosql.maintenance.spi.CountDownPredicate; +import org.apache.polaris.persistence.nosql.maintenance.spi.PerRealmRetainedIdentifier; +import org.apache.polaris.persistence.nosql.maintenance.spi.RetainedCollector; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +class RealmManagementRetainedIdentifier implements PerRealmRetainedIdentifier { + private static final Logger LOGGER = + LoggerFactory.getLogger(RealmManagementRetainedIdentifier.class); + + @Override + public String name() { + return "Realm management"; + } + + @Override + public boolean identifyRetained(@Nonnull RetainedCollector collector) { + if (!collector.isSystemRealm()) { + return false; + } + + // TODO follow-up: configurable limit number of historic realm states to retain + try { + collector.refRetainIndexToSingleObj( + REALMS_REF_NAME, + RealmsStateObj.class, + new CountDownPredicate<>(10), + RealmsStateObj::realmIndex); + } catch (ReferenceNotFoundException e) { + // logged, but otherwise ignored + LOGGER.debug( + "Reference '{}' not found while identifying retained items: {}, this might be expected", + REALMS_REF_NAME, + e.getMessage()); + } + + // Intentionally return false, let the maintenance service's identifier decide + return false; + } +} diff --git a/persistence/nosql/realms/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/realms/store/RealmObj.java b/persistence/nosql/realms/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/realms/store/RealmObj.java new file mode 100644 index 0000000000..4d1db80892 --- /dev/null +++ b/persistence/nosql/realms/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/realms/store/RealmObj.java @@ -0,0 +1,62 @@ +/* + * 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.polaris.persistence.nosql.realms.store; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.time.Instant; +import java.util.Map; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.Obj; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.realms.api.RealmDefinition; + +/** Represents the persisted state of a {@link RealmDefinition}. */ +@PolarisImmutable +@JsonSerialize(as = ImmutableRealmObj.class) +@JsonDeserialize(as = ImmutableRealmObj.class) +public interface RealmObj extends Obj { + ObjType TYPE = new RealmObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + Instant created(); + + Instant updated(); + + RealmDefinition.RealmStatus status(); + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + Map properties(); + + static ImmutableRealmObj.Builder builder() { + return ImmutableRealmObj.builder(); + } + + final class RealmObjType extends AbstractObjType { + public RealmObjType() { + super("rlm", "Realm", RealmObj.class); + } + } +} diff --git a/persistence/nosql/realms/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/realms/store/RealmStoreImpl.java b/persistence/nosql/realms/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/realms/store/RealmStoreImpl.java new file mode 100644 index 0000000000..534233ca95 --- /dev/null +++ b/persistence/nosql/realms/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/realms/store/RealmStoreImpl.java @@ -0,0 +1,276 @@ +/* + * 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.polaris.persistence.nosql.realms.store; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static java.lang.String.format; +import static org.apache.polaris.persistence.nosql.api.Realms.SYSTEM_REALM_ID; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.apache.polaris.persistence.nosql.realms.store.RealmsStateObj.REALMS_REF_NAME; + +import com.google.common.collect.Streams; +import jakarta.annotation.Nonnull; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.ArrayList; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.StreamUtil; +import org.apache.polaris.persistence.nosql.api.SystemPersistence; +import org.apache.polaris.persistence.nosql.api.commit.Committer; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.realms.api.RealmAlreadyExistsException; +import org.apache.polaris.persistence.nosql.realms.api.RealmDefinition; +import org.apache.polaris.persistence.nosql.realms.api.RealmNotFoundException; +import org.apache.polaris.persistence.nosql.realms.spi.RealmStore; + +@ApplicationScoped +class RealmStoreImpl implements RealmStore { + private final Persistence systemPersistence; + private final Committer committer; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + RealmStoreImpl(@Nonnull @SystemPersistence Persistence systemPersistence) { + checkArgument( + SYSTEM_REALM_ID.equals(systemPersistence.realmId()), + "Realms management must happen in the %s realm", + SYSTEM_REALM_ID); + + this.systemPersistence = systemPersistence; + + this.committer = + systemPersistence.createCommitter(REALMS_REF_NAME, RealmsStateObj.class, RealmObj.class); + } + + @PostConstruct + void init() { + // Do this in a @PostConstruct method as it involves I/O, which isn't a good thing to do in a + // constructor, especially in CDI + systemPersistence.createReferenceSilent(REALMS_REF_NAME); + } + + @Override + public Stream list() { + var realmsIndex = + systemPersistence + .fetchReferenceHead(REALMS_REF_NAME, RealmsStateObj.class) + .map(realms -> realms.realmIndex().indexForRead(systemPersistence, OBJ_REF_SERIALIZER)); + return realmsIndex.stream() + .flatMap( + entries -> + StreamUtil.bucketized( + Streams.stream(entries), + bucket -> { + var objRefs = + bucket.stream().map(Map.Entry::getValue).toArray(ObjRef[]::new); + var objs = systemPersistence.fetchMany(RealmObj.class, objRefs); + var defs = new ArrayList(bucket.size()); + + for (int i = 0; i < objs.length; i++) { + var obj = objs[i]; + if (obj != null) { + defs.add(objToDefinition(bucket.get(i).getKey().toString(), obj)); + } + } + return defs; + }, + systemPersistence.params().bucketizedBulkFetchSize()) + .filter(Objects::nonNull)); + } + + @Override + public Optional get(String realmId) { + return systemPersistence + .fetchReferenceHead(REALMS_REF_NAME, RealmsStateObj.class) + .flatMap(realms -> Optional.ofNullable(realmFromState(realms, realmId))); + } + + @SuppressWarnings("DuplicatedCode") // looks similar, but extracting isn't worth it + @Override + public RealmDefinition create(String realmId, RealmDefinition definition) { + var realm = + committer.commitRuntimeException( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + var current = refObj.orElse(null); + + var key = IndexKey.key(realmId); + + var index = + current != null + ? current.realmIndex().asUpdatableIndex(systemPersistence, OBJ_REF_SERIALIZER) + : IndexContainer.newUpdatableIndex(systemPersistence, OBJ_REF_SERIALIZER); + if (index.contains(key)) { + throw new RealmAlreadyExistsException( + format("A realm with ID '%s' already exists", realmId)); + } + + var obj = + state.writeIfNew( + "realm", + RealmObj.builder() + .created(definition.created()) + .updated(definition.updated()) + .id(systemPersistence.generateId()) + .status(definition.status()) + .properties(definition.properties()) + .build(), + RealmObj.class); + + index.put(key, objRef(obj)); + + var newRealms = + RealmsStateObj.builder() + .realmIndex(index.toIndexed("idx-", state::writeOrReplace)); + + return state.commitResult(obj, newRealms, refObj); + }); + + return objToDefinition(realmId, realm.orElseThrow()); + } + + @SuppressWarnings("DuplicatedCode") // looks similar, but extracting isn't worth it + @Override + public RealmDefinition update( + String realmId, Function updater) { + var realm = + committer.commitRuntimeException( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + var current = refObj.orElse(null); + if (current == null) { + throw getRealmNotFoundException(realmId); + } + + var key = IndexKey.key(realmId); + + var index = + current.realmIndex().asUpdatableIndex(systemPersistence, OBJ_REF_SERIALIZER); + var currentObjId = index.get(key); + if (currentObjId == null) { + throw getRealmNotFoundException(realmId); + } + + var currentObj = systemPersistence.fetch(currentObjId, RealmObj.class); + if (currentObj == null) { + throw realmObjNotFoundException(realmId); + } + + var update = updater.apply(objToDefinition(realmId, currentObj)); + + var obj = + state.writeIfNew( + "realm", + RealmObj.builder() + .created(currentObj.created()) + .updated(update.updated()) + .id(systemPersistence.generateId()) + .status(update.status()) + .properties(update.properties()) + .build(), + RealmObj.class); + + index.put(key, objRef(obj)); + + var newRealms = + RealmsStateObj.builder() + .realmIndex(index.toIndexed("idx-", state::writeOrReplace)); + + return state.commitResult(obj, newRealms, refObj); + }); + + return objToDefinition(realmId, realm.orElseThrow()); + } + + @SuppressWarnings("DuplicatedCode") // looks similar, but extracting isn't worth it + @Override + public void delete(String realmId, Consumer callback) { + committer.commitRuntimeException( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + var current = refObj.orElse(null); + if (current == null) { + throw getRealmNotFoundException(realmId); + } + + var key = IndexKey.key(realmId); + + var index = current.realmIndex().asUpdatableIndex(systemPersistence, OBJ_REF_SERIALIZER); + var currentObjId = index.get(key); + if (currentObjId == null) { + throw getRealmNotFoundException(realmId); + } + + var currentObj = systemPersistence.fetch(currentObjId, RealmObj.class); + if (currentObj == null) { + throw realmObjNotFoundException(realmId); + } + + callback.accept(objToDefinition(realmId, currentObj)); + + index.remove(key); + + var newRealms = + RealmsStateObj.builder().realmIndex(index.toIndexed("idx-", state::writeOrReplace)); + + return state.commitResult(currentObj, newRealms, refObj); + }); + } + + private RealmDefinition realmFromState(RealmsStateObj realms, String realmId) { + var index = realms.realmIndex().indexForRead(systemPersistence, OBJ_REF_SERIALIZER); + var realmDefId = index.get(IndexKey.key(realmId)); + if (realmDefId == null) { + return null; + } + var obj = systemPersistence.fetch(realmDefId, RealmObj.class); + checkState(obj != null, "No realm definition object for realm ID '%s'", realmId); + return objToDefinition(realmId, obj); + } + + private static RealmDefinition objToDefinition(String realmId, RealmObj obj) { + return RealmDefinition.builder() + .id(realmId) + .created(obj.created()) + .updated(obj.updated()) + .status(obj.status()) + .properties(obj.properties()) + .build(); + } + + private static RealmNotFoundException getRealmNotFoundException(String realmId) { + return new RealmNotFoundException(format("No realm with ID '%s' exists", realmId)); + } + + private static RealmNotFoundException realmObjNotFoundException(String realmId) { + return new RealmNotFoundException( + format("RealmObj for realm with ID '%s' does not exist", realmId)); + } +} diff --git a/persistence/nosql/realms/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/realms/store/RealmsStateObj.java b/persistence/nosql/realms/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/realms/store/RealmsStateObj.java new file mode 100644 index 0000000000..3b1993f3ce --- /dev/null +++ b/persistence/nosql/realms/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/realms/store/RealmsStateObj.java @@ -0,0 +1,61 @@ +/* + * 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.polaris.persistence.nosql.realms.store; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.index.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.BaseCommitObj; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; + +/** Represents the persisted and system-wide consistent state of all realms. */ +@PolarisImmutable +@JsonSerialize(as = ImmutableRealmsStateObj.class) +@JsonDeserialize(as = ImmutableRealmsStateObj.class) +public interface RealmsStateObj extends BaseCommitObj { + ObjType TYPE = new RealmStateObjType(); + String REALMS_REF_NAME = "realms"; + + @Override + default ObjType type() { + return TYPE; + } + + /** + * Index of all realms by ID (via {@link IndexKey#key(String)}) to the {@link ObjRef}s referencing + * {@link RealmObj}s. + */ + IndexContainer realmIndex(); + + static ImmutableRealmsStateObj.Builder builder() { + return ImmutableRealmsStateObj.builder(); + } + + final class RealmStateObjType extends AbstractObjType { + public RealmStateObjType() { + super("realm-state", "Realms State", RealmsStateObj.class); + } + } + + interface Builder extends BaseCommitObj.Builder {} +} diff --git a/persistence/nosql/realms/store-nosql/src/main/resources/META-INF/beans.xml b/persistence/nosql/realms/store-nosql/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/realms/store-nosql/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/realms/store-nosql/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType b/persistence/nosql/realms/store-nosql/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType new file mode 100644 index 0000000000..dcd3e3440d --- /dev/null +++ b/persistence/nosql/realms/store-nosql/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType @@ -0,0 +1,21 @@ +# +# 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. +# + +org.apache.polaris.persistence.nosql.realms.store.RealmObj$RealmObjType +org.apache.polaris.persistence.nosql.realms.store.RealmsStateObj$RealmStateObjType diff --git a/persistence/nosql/realms/store-nosql/src/test/java/org/apache/polaris/persistence/nosql/realms/store/TestRealmStoreIntegration.java b/persistence/nosql/realms/store-nosql/src/test/java/org/apache/polaris/persistence/nosql/realms/store/TestRealmStoreIntegration.java new file mode 100644 index 0000000000..69cf472618 --- /dev/null +++ b/persistence/nosql/realms/store-nosql/src/test/java/org/apache/polaris/persistence/nosql/realms/store/TestRealmStoreIntegration.java @@ -0,0 +1,220 @@ +/* + * 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.polaris.persistence.nosql.realms.store; + +import static java.lang.String.format; +import static java.time.Instant.now; +import static org.apache.polaris.persistence.nosql.api.Realms.SYSTEM_REALM_ID; +import static org.apache.polaris.persistence.nosql.realms.api.RealmDefinition.RealmStatus.ACTIVE; +import static org.apache.polaris.persistence.nosql.realms.api.RealmDefinition.RealmStatus.CREATED; +import static org.apache.polaris.persistence.nosql.realms.api.RealmDefinition.RealmStatus.INACTIVE; +import static org.apache.polaris.persistence.nosql.realms.api.RealmDefinition.RealmStatus.INITIALIZING; +import static org.apache.polaris.persistence.nosql.realms.api.RealmDefinition.RealmStatus.PURGED; +import static org.apache.polaris.persistence.nosql.realms.api.RealmDefinition.RealmStatus.PURGING; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import jakarta.inject.Inject; +import java.util.Map; +import java.util.function.IntFunction; +import java.util.stream.IntStream; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.PersistenceParams; +import org.apache.polaris.persistence.nosql.realms.api.RealmAlreadyExistsException; +import org.apache.polaris.persistence.nosql.realms.api.RealmDefinition; +import org.apache.polaris.persistence.nosql.realms.api.RealmExpectedStateMismatchException; +import org.apache.polaris.persistence.nosql.realms.api.RealmManagement; +import org.apache.polaris.persistence.nosql.realms.api.RealmNotFoundException; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.jboss.weld.junit5.EnableWeld; +import org.jboss.weld.junit5.WeldInitiator; +import org.jboss.weld.junit5.WeldSetup; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +@EnableWeld +public class TestRealmStoreIntegration { + @InjectSoftAssertions protected SoftAssertions soft; + @WeldSetup WeldInitiator weld = WeldInitiator.performDefaultDiscovery(); + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + RealmManagement realmManagement; + + @Test + public void nonSystemPersistence() { + var nonSystemPersistence = mock(Persistence.class); + var params = mock(PersistenceParams.class); + when(nonSystemPersistence.realmId()).thenReturn("nonSystemPersistence"); + when(nonSystemPersistence.params()).thenReturn(params); + soft.assertThatIllegalArgumentException() + .isThrownBy(() -> new RealmStoreImpl(nonSystemPersistence)) + .withMessage("Realms management must happen in the ::system:: realm"); + } + + @Test + public void createUpdateDelete() { + var something = + RealmDefinition.builder() + .id("something") + .created(now()) + .updated(now()) + .status(ACTIVE) + .build(); + var another = + RealmDefinition.builder() + .id("another") + .created(now()) + .updated(now()) + .status(ACTIVE) + .build(); + + soft.assertThatIllegalArgumentException() + .isThrownBy(() -> realmManagement.create(SYSTEM_REALM_ID)) + .withMessage("Invalid realm ID '%s'", SYSTEM_REALM_ID); + soft.assertThatIllegalArgumentException() + .isThrownBy(() -> realmManagement.create("::something")) + .withMessage("Invalid realm ID '%s'", "::something"); + soft.assertThatIllegalArgumentException() + .isThrownBy( + () -> + realmManagement.update( + something.withId("::something"), something.withId("::something"))) + .withMessage("Invalid realm ID '%s'", "::something"); + soft.assertThatIllegalArgumentException() + .isThrownBy(() -> realmManagement.delete(something.withId("::something"))) + .withMessage("Invalid realm ID '%s'", "::something"); + + // empty index + soft.assertThatThrownBy( + () -> + realmManagement.update( + something, RealmDefinition.builder().from(something).build())) + .isInstanceOf(RealmNotFoundException.class) + .hasMessage("No realm with ID 'something' exists"); + soft.assertThatThrownBy(() -> realmManagement.delete(something.withStatus(PURGED))) + .hasMessage("No realm with ID 'something' exists"); + + var created = realmManagement.create(something.id()); + soft.assertThat(created).extracting(RealmDefinition::id).isEqualTo(something.id()); + soft.assertThatThrownBy(() -> realmManagement.create(something.id())) + .isInstanceOf(RealmAlreadyExistsException.class) + .hasMessage("A realm with ID 'something' already exists"); + var gotOpt = realmManagement.get(something.id()); + soft.assertThat(gotOpt).contains(created); + var got = gotOpt.orElseThrow(); + + var createdAnother = realmManagement.create(another.id()); + soft.assertThat(createdAnother).extracting(RealmDefinition::id).isEqualTo(another.id()); + + // RealmsStateObj present + soft.assertThatThrownBy( + () -> realmManagement.update(something.withId("foo"), something.withId("foo"))) + .isInstanceOf(RealmNotFoundException.class) + .hasMessage("No realm with ID 'foo' exists"); + soft.assertThatThrownBy( + () -> realmManagement.delete(something.withId("foo").withStatus(PURGED))) + .isInstanceOf(RealmNotFoundException.class) + .hasMessage("No realm with ID 'foo' exists"); + + // Update with different realm-IDs (duh!) + soft.assertThatIllegalArgumentException() + .isThrownBy( + () -> + realmManagement.update( + got, RealmDefinition.builder().from(got).id("something-else").build())); + // Update with different expected state + soft.assertThatThrownBy( + () -> + realmManagement.update( + RealmDefinition.builder().from(got).putProperty("foo", "bar").build(), + RealmDefinition.builder().from(got).putProperty("meep", "meep").build())) + .isInstanceOf(RealmExpectedStateMismatchException.class) + .hasMessage("Realm '%s' does not match the expected state", got.id()); + + var updated = + realmManagement.update( + got, RealmDefinition.builder().from(got).putProperty("foo", "bar").build()); + soft.assertThat(updated) + .extracting(RealmDefinition::id, RealmDefinition::properties) + .containsExactly(something.id(), Map.of("foo", "bar")); + var got2Opt = realmManagement.get(something.id()); + soft.assertThat(got2Opt).contains(updated); + var got2 = got2Opt.orElseThrow(); + + soft.assertThatIllegalArgumentException() + .isThrownBy(() -> realmManagement.delete(got2)) + .withMessage("Realm '%s' must be in state PURGED to be deleted", got2.id()); + var initializing = + realmManagement.update( + got2, RealmDefinition.builder().from(got2).status(INITIALIZING).build()); + var active = + realmManagement.update( + initializing, RealmDefinition.builder().from(initializing).status(ACTIVE).build()); + soft.assertThatIllegalArgumentException() + .isThrownBy(() -> realmManagement.delete(active)) + .withMessage("Realm '%s' must be in state PURGED to be deleted", active.id()); + soft.assertThatIllegalArgumentException() + .isThrownBy( + () -> + realmManagement.update( + active, RealmDefinition.builder().from(active).status(CREATED).build())) + .withMessage( + "Invalid realm state transition from ACTIVE to CREATED for realm '%s'", active.id()); + var inactive = + realmManagement.update( + active, RealmDefinition.builder().from(got2).status(INACTIVE).build()); + var purging = + realmManagement.update( + inactive, RealmDefinition.builder().from(inactive).status(PURGING).build()); + soft.assertThat(purging).extracting(RealmDefinition::status).isSameAs(PURGING); + var purged = + realmManagement.update( + purging, RealmDefinition.builder().from(inactive).status(PURGED).build()); + soft.assertThat(purged).extracting(RealmDefinition::status).isSameAs(PURGED); + soft.assertThatCode(() -> realmManagement.delete(purged)).doesNotThrowAnyException(); + + soft.assertThat(realmManagement.get(something.id())).isEmpty(); + + soft.assertThat(realmManagement.get(another.id())).contains(createdAnother); + } + + @Test + public void list() { + var toRealmId = (IntFunction) i -> format("realm_%05d", i); + + // Check that the bucketizing used in .list() implementation works correctly. Need to iterate + // more often than the (default) PersistenceParams.bucketizedBulkFetchSize() value. + for (int i = 0; i < 47; i++) { + try (var realms = realmManagement.list()) { + var realmDefs = realms.toList(); + soft.assertThat(realmDefs) + .describedAs("i=%d", i) + .hasSize(i) + .map(RealmDefinition::id) + .containsExactlyElementsOf(IntStream.range(0, i).mapToObj(toRealmId).toList()); + + realmManagement.create(toRealmId.apply(i)); + } + } + } +} diff --git a/persistence/nosql/realms/store-nosql/src/test/resources/logback-test.xml b/persistence/nosql/realms/store-nosql/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..fb74fc2c54 --- /dev/null +++ b/persistence/nosql/realms/store-nosql/src/test/resources/logback-test.xml @@ -0,0 +1,30 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/persistence/nosql/realms/store-nosql/src/test/resources/weld.properties b/persistence/nosql/realms/store-nosql/src/test/resources/weld.properties new file mode 100644 index 0000000000..c26169e0e1 --- /dev/null +++ b/persistence/nosql/realms/store-nosql/src/test/resources/weld.properties @@ -0,0 +1,21 @@ +# +# 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. +# + +# See https://bugs.openjdk.org/browse/JDK-8349545 +org.jboss.weld.bootstrap.concurrentDeployment=false diff --git a/polaris-core/src/testFixtures/java/org/apache/polaris/core/persistence/BasePolarisMetaStoreManagerTest.java b/polaris-core/src/testFixtures/java/org/apache/polaris/core/persistence/BasePolarisMetaStoreManagerTest.java index f8816260a2..771fe41eb1 100644 --- a/polaris-core/src/testFixtures/java/org/apache/polaris/core/persistence/BasePolarisMetaStoreManagerTest.java +++ b/polaris-core/src/testFixtures/java/org/apache/polaris/core/persistence/BasePolarisMetaStoreManagerTest.java @@ -65,7 +65,7 @@ public abstract class BasePolarisMetaStoreManagerTest { protected final MutableClock clock = MutableClock.of(Instant.now(), ZoneOffset.UTC); - private PolarisTestMetaStoreManager polarisTestMetaStoreManager; + protected PolarisTestMetaStoreManager polarisTestMetaStoreManager; @BeforeEach public void setupPolarisMetaStoreManager() { diff --git a/polaris-core/src/testFixtures/java/org/apache/polaris/core/persistence/PolarisTestMetaStoreManager.java b/polaris-core/src/testFixtures/java/org/apache/polaris/core/persistence/PolarisTestMetaStoreManager.java index b32a4059da..bf999e3ff2 100644 --- a/polaris-core/src/testFixtures/java/org/apache/polaris/core/persistence/PolarisTestMetaStoreManager.java +++ b/polaris-core/src/testFixtures/java/org/apache/polaris/core/persistence/PolarisTestMetaStoreManager.java @@ -98,6 +98,14 @@ public PolarisTestMetaStoreManager( this.doRetry = false; } + public PolarisCallContext polarisCallContext() { + return polarisCallContext; + } + + public PolarisMetaStoreManager polarisMetaStoreManager() { + return polarisMetaStoreManager; + } + public void forceRetry() { this.doRetry = true; } diff --git a/runtime/admin/build.gradle.kts b/runtime/admin/build.gradle.kts index 103da5c79f..b00ca1c2a6 100644 --- a/runtime/admin/build.gradle.kts +++ b/runtime/admin/build.gradle.kts @@ -32,16 +32,27 @@ dependencies { compileOnly("com.fasterxml.jackson.core:jackson-annotations") + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-persistence-nosql-maintenance-api")) + runtimeOnly(project(":polaris-persistence-nosql-metastore")) + runtimeOnly(project(":polaris-persistence-nosql-cdi-quarkus")) + runtimeOnly(project(":polaris-persistence-nosql-maintenance-impl")) + runtimeOnly(project(":polaris-persistence-nosql-authz-store-nosql")) + runtimeOnly(project(":polaris-relational-jdbc")) runtimeOnly("org.postgresql:postgresql") implementation("io.quarkus:quarkus-jdbc-postgresql") + implementation(enforcedPlatform(libs.quarkus.bom)) implementation("io.quarkus:quarkus-picocli") implementation("io.quarkus:quarkus-container-image-docker") implementation(project(":polaris-runtime-common")) + compileOnly("com.fasterxml.jackson.core:jackson-annotations") + compileOnly("com.fasterxml.jackson.core:jackson-databind") + testImplementation(project(":polaris-runtime-test-common")) testFixturesApi(project(":polaris-core")) @@ -51,7 +62,8 @@ dependencies { testFixturesApi(project(":polaris-container-spec-helper")) testFixturesApi(platform(libs.testcontainers.bom)) testFixturesApi("org.testcontainers:testcontainers") - testFixturesApi("org.testcontainers:postgresql") + testFixturesApi("org.testcontainers:testcontainers-postgresql") + testFixturesImplementation(testFixtures(project(":polaris-persistence-nosql-mongodb"))) testRuntimeOnly("org.postgresql:postgresql") } diff --git a/runtime/admin/src/main/java/org/apache/polaris/admintool/BaseCommand.java b/runtime/admin/src/main/java/org/apache/polaris/admintool/BaseCommand.java index c56fe97169..00c8f7e01b 100644 --- a/runtime/admin/src/main/java/org/apache/polaris/admintool/BaseCommand.java +++ b/runtime/admin/src/main/java/org/apache/polaris/admintool/BaseCommand.java @@ -18,9 +18,7 @@ */ package org.apache.polaris.admintool; -import jakarta.inject.Inject; import java.util.concurrent.Callable; -import org.apache.polaris.core.persistence.MetaStoreManagerFactory; import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Spec; @@ -30,7 +28,5 @@ public abstract class BaseCommand implements Callable { public static final int EXIT_CODE_BOOTSTRAP_ERROR = 3; public static final int EXIT_CODE_PURGE_ERROR = 4; - @Inject MetaStoreManagerFactory metaStoreManagerFactory; - - @Spec CommandSpec spec; + @Spec protected CommandSpec spec; } diff --git a/runtime/admin/src/main/java/org/apache/polaris/admintool/BaseMetaStoreCommand.java b/runtime/admin/src/main/java/org/apache/polaris/admintool/BaseMetaStoreCommand.java new file mode 100644 index 0000000000..5a28b54799 --- /dev/null +++ b/runtime/admin/src/main/java/org/apache/polaris/admintool/BaseMetaStoreCommand.java @@ -0,0 +1,26 @@ +/* + * 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.polaris.admintool; + +import jakarta.inject.Inject; +import org.apache.polaris.core.persistence.MetaStoreManagerFactory; + +public abstract class BaseMetaStoreCommand extends BaseCommand { + @Inject protected MetaStoreManagerFactory metaStoreManagerFactory; +} diff --git a/runtime/admin/src/main/java/org/apache/polaris/admintool/BootstrapCommand.java b/runtime/admin/src/main/java/org/apache/polaris/admintool/BootstrapCommand.java index bfa59fab8d..82d92f4e18 100644 --- a/runtime/admin/src/main/java/org/apache/polaris/admintool/BootstrapCommand.java +++ b/runtime/admin/src/main/java/org/apache/polaris/admintool/BootstrapCommand.java @@ -33,7 +33,7 @@ name = "bootstrap", mixinStandardHelpOptions = true, description = "Bootstraps realms and root principal credentials.") -public class BootstrapCommand extends BaseCommand { +public class BootstrapCommand extends BaseMetaStoreCommand { @CommandLine.Mixin InputOptions inputOptions; diff --git a/runtime/admin/src/main/java/org/apache/polaris/admintool/PolarisAdminTool.java b/runtime/admin/src/main/java/org/apache/polaris/admintool/PolarisAdminTool.java index 2cb03f07c6..06cb49cfd8 100644 --- a/runtime/admin/src/main/java/org/apache/polaris/admintool/PolarisAdminTool.java +++ b/runtime/admin/src/main/java/org/apache/polaris/admintool/PolarisAdminTool.java @@ -17,6 +17,7 @@ import io.quarkus.picocli.runtime.annotations.TopCommand; import java.io.PrintWriter; +import org.apache.polaris.admintool.maintenance.PersistenceMaintenanceCommand; import org.apache.polaris.version.PolarisVersionProvider; import picocli.CommandLine.Command; import picocli.CommandLine.HelpCommand; @@ -31,8 +32,9 @@ HelpCommand.class, BootstrapCommand.class, PurgeCommand.class, + PersistenceMaintenanceCommand.class, }) -public class PolarisAdminTool extends BaseCommand { +public class PolarisAdminTool extends BaseMetaStoreCommand { @Override public Integer call() { diff --git a/runtime/admin/src/main/java/org/apache/polaris/admintool/PurgeCommand.java b/runtime/admin/src/main/java/org/apache/polaris/admintool/PurgeCommand.java index 600ae0b1a5..772d311d6b 100644 --- a/runtime/admin/src/main/java/org/apache/polaris/admintool/PurgeCommand.java +++ b/runtime/admin/src/main/java/org/apache/polaris/admintool/PurgeCommand.java @@ -26,7 +26,7 @@ name = "purge", mixinStandardHelpOptions = true, description = "Purge realms and all associated entities.") -public class PurgeCommand extends BaseCommand { +public class PurgeCommand extends BaseMetaStoreCommand { @CommandLine.Option( names = {"-r", "--realm"}, diff --git a/runtime/admin/src/main/java/org/apache/polaris/admintool/config/AdminToolProducers.java b/runtime/admin/src/main/java/org/apache/polaris/admintool/config/AdminToolProducers.java index e6251b2c88..28a43a6c37 100644 --- a/runtime/admin/src/main/java/org/apache/polaris/admintool/config/AdminToolProducers.java +++ b/runtime/admin/src/main/java/org/apache/polaris/admintool/config/AdminToolProducers.java @@ -32,15 +32,16 @@ import org.apache.polaris.core.storage.PolarisStorageConfigurationInfo; import org.apache.polaris.core.storage.PolarisStorageIntegration; import org.apache.polaris.core.storage.PolarisStorageIntegrationProvider; -import org.eclipse.microprofile.config.inject.ConfigProperty; public class AdminToolProducers { @Produces public MetaStoreManagerFactory metaStoreManagerFactory( - @ConfigProperty(name = "polaris.persistence.type") String persistenceType, + QuarkusPersistenceConfiguration persistenceConfiguration, @Any Instance metaStoreManagerFactories) { - return metaStoreManagerFactories.select(Identifier.Literal.of(persistenceType)).get(); + return metaStoreManagerFactories + .select(Identifier.Literal.of(persistenceConfiguration.type())) + .get(); } @Produces diff --git a/runtime/admin/src/main/java/org/apache/polaris/admintool/config/QuarkusPersistenceConfiguration.java b/runtime/admin/src/main/java/org/apache/polaris/admintool/config/QuarkusPersistenceConfiguration.java new file mode 100644 index 0000000000..9e1960d78a --- /dev/null +++ b/runtime/admin/src/main/java/org/apache/polaris/admintool/config/QuarkusPersistenceConfiguration.java @@ -0,0 +1,31 @@ +/* + * 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.polaris.admintool.config; + +import io.smallrye.config.ConfigMapping; + +@ConfigMapping(prefix = "polaris.persistence") +public interface QuarkusPersistenceConfiguration { + + /** + * The type of the persistence to use. Must be a registered {@link + * org.apache.polaris.core.persistence.MetaStoreManagerFactory} identifier. + */ + String type(); +} diff --git a/runtime/admin/src/main/java/org/apache/polaris/admintool/maintenance/BaseMaintenanceCommand.java b/runtime/admin/src/main/java/org/apache/polaris/admintool/maintenance/BaseMaintenanceCommand.java new file mode 100644 index 0000000000..217b9da7c9 --- /dev/null +++ b/runtime/admin/src/main/java/org/apache/polaris/admintool/maintenance/BaseMaintenanceCommand.java @@ -0,0 +1,182 @@ +/* + * 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.polaris.admintool.maintenance; + +import jakarta.inject.Inject; +import java.io.PrintWriter; +import java.time.Instant; +import org.apache.polaris.admintool.BaseCommand; +import org.apache.polaris.persistence.nosql.api.backend.Backend; +import org.apache.polaris.persistence.nosql.api.obj.ObjTypes; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceConfig; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceRunInformation; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceRunInformation.MaintenanceStats; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceRunSpec; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceService; + +public abstract class BaseMaintenanceCommand extends BaseCommand { + @Inject protected MaintenanceService maintenanceService; + @Inject protected Backend backend; + @Inject protected MaintenanceConfig maintenanceConfig; + + protected void checkInMemory() { + if ("InMemory".equals(backend.type())) { + var err = spec.commandLine().getErr(); + + err.println(); + err.println("Running persistence-maintenance against InMemory is useless..."); + err.println(); + } + } + + protected MaintenanceRunSpec printRealmStates() { + var out = spec.commandLine().getOut(); + + var runSpec = maintenanceService.buildMaintenanceRunSpec(); + out.println(); + out.printf("Process system realm: %s%n", runSpec.includeSystemRealm()); + out.println("Realms to process:"); + var realms = runSpec.realmsToProcess(); + if (realms.isEmpty()) { + out.println("(none)"); + } + for (var realm : realms) { + out.printf(" %s%n", realm); + } + + out.println("Realms to purge:"); + realms = runSpec.realmsToPurge(); + if (realms.isEmpty()) { + out.println("(none)"); + } + for (var realm : realms) { + out.printf(" %s%n", realm); + } + + return runSpec; + } + + protected void printMaintenanceConfig() { + var out = spec.commandLine().getOut(); + + out.println(); + out.println("Maintenance configuration:"); + out.printf( + " created-at grace time: %s%n", + maintenanceConfig + .createdAtGraceTime() + .orElse(MaintenanceConfig.DEFAULT_CREATED_AT_GRACE_TIME)); + out.printf( + " delete batch size: %s%n", + maintenanceConfig.deleteBatchSize().orElse(MaintenanceConfig.DEFAULT_DELETE_BATCH_SIZE)); + out.printf( + " retained runs: %s%n", + maintenanceConfig.retainedRuns().orElse(MaintenanceConfig.DEFAULT_RETAINED_RUNS)); + + out.printf( + " expected object count: %d%n", + maintenanceConfig.expectedObjCount().orElse(MaintenanceConfig.DEFAULT_EXPECTED_OBJ_COUNT)); + out.printf( + " expected reference count: %d%n", + maintenanceConfig + .expectedReferenceCount() + .orElse(MaintenanceConfig.DEFAULT_EXPECTED_REFERENCE_COUNT)); + out.printf( + " last-run multiplier: %f%n", + maintenanceConfig + .countFromLastRunMultiplier() + .orElse(MaintenanceConfig.DEFAULT_COUNT_FROM_LAST_RUN_MULTIPLIER)); + out.printf( + " initialized FPP: %f%n", + maintenanceConfig.filterInitializedFpp().orElse(MaintenanceConfig.DEFAULT_INITIALIZED_FPP)); + out.printf( + " expected FPP: %f%n", + maintenanceConfig + .maxAcceptableFilterFpp() + .orElse(MaintenanceConfig.DEFAULT_MAX_ACCEPTABLE_FPP)); + + out.printf( + " reference scan rate limit / sec: %s%n", + maintenanceConfig.referenceScanRateLimitPerSecond().stream() + .mapToObj(Integer::toString) + .findFirst() + .orElse("(unlimited)")); + out.printf( + " object scan rate limit / sec: %s%n", + maintenanceConfig.objectScanRateLimitPerSecond().stream() + .mapToObj(Integer::toString) + .findFirst() + .orElse("(unlimited)")); + } + + protected void printRunInformation(MaintenanceRunInformation info, boolean expert) { + var out = spec.commandLine().getOut(); + out.println(); + out.println( + "=================================================================================="); + out.println(); + out.printf("Run started: %s%n", info.started()); + out.printf( + " status: %s%n", + info.statusMessage().orElse("(no exceptional information, all good so far)")); + out.printf(" finished: %s%n", info.finished().map(Instant::toString).orElse("(running)")); + out.printf(" details: %s%n", info.detailedInformation().orElse("-")); + + out.println(); + out.println("Realms:"); + out.printf(" purged: %d%n", info.purgedRealms().orElse(0)); + + out.println(); + out.println("References:"); + if (expert) { + out.printf(" identified: %d%n", info.identifiedReferences().orElse(0)); + } + info.referenceStats().ifPresent(stats -> printStats(out, " ", stats)); + info.perRealmReferenceStats() + .forEach( + (realm, stats) -> { + out.printf(" Realm: %s%n", realm); + printStats(out, " ", stats); + }); + + out.println(); + out.println("Objects:"); + if (expert) { + out.printf(" identified: %d%n", info.identifiedObjs().orElse(0)); + } + info.objStats().ifPresent(stats -> printStats(out, " ", stats)); + info.perRealmPerObjTypeStats() + .forEach( + (realm, perTypeStats) -> { + out.printf(" Realm: %s%n", realm); + perTypeStats.forEach( + (type, stats) -> { + out.printf(" Type: %s (%s)%n", type, ObjTypes.objTypeById(type).name()); + printStats(out, " ", stats); + }); + }); + } + + private void printStats(PrintWriter out, String indent, MaintenanceStats stats) { + out.printf("%s scanned: %d%n", indent, stats.scanned().orElse(0L)); + out.printf("%s retained: %d%n", indent, stats.retained().orElse(0L)); + out.printf("%s too new: %d%n", indent, stats.newer().orElse(0L)); + out.printf("%s purged: %d%n", indent, stats.purged().orElse(0L)); + } +} diff --git a/runtime/admin/src/main/java/org/apache/polaris/admintool/maintenance/PersistenceMaintenanceCommand.java b/runtime/admin/src/main/java/org/apache/polaris/admintool/maintenance/PersistenceMaintenanceCommand.java new file mode 100644 index 0000000000..9cb89854f6 --- /dev/null +++ b/runtime/admin/src/main/java/org/apache/polaris/admintool/maintenance/PersistenceMaintenanceCommand.java @@ -0,0 +1,52 @@ +/* + * 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.polaris.admintool.maintenance; + +import picocli.CommandLine; + +@CommandLine.Command( + name = "maintenance", + subcommands = { + PersistenceMaintenanceLogCommand.class, + PersistenceMaintenanceRunCommand.class, + }, + mixinStandardHelpOptions = true, + description = "Polaris persistence maintenance.") +public class PersistenceMaintenanceCommand extends BaseMaintenanceCommand { + + @Override + public Integer call() { + var out = spec.commandLine().getOut(); + + out.println("Polaris persistence maintenance has multiple subcommands,"); + out.println("use the 'help maintenance' command."); + out.println(); + + checkInMemory(); + + out.println(); + out.println("Information: selected persistence backend: " + backend.type()); + + printMaintenanceConfig(); + + printRealmStates(); + + return 0; + } +} diff --git a/runtime/admin/src/main/java/org/apache/polaris/admintool/maintenance/PersistenceMaintenanceLogCommand.java b/runtime/admin/src/main/java/org/apache/polaris/admintool/maintenance/PersistenceMaintenanceLogCommand.java new file mode 100644 index 0000000000..d9995c8301 --- /dev/null +++ b/runtime/admin/src/main/java/org/apache/polaris/admintool/maintenance/PersistenceMaintenanceLogCommand.java @@ -0,0 +1,44 @@ +/* + * 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.polaris.admintool.maintenance; + +import picocli.CommandLine; + +@CommandLine.Command( + name = "log", + mixinStandardHelpOptions = true, + description = "Show Polaris persistence maintenance log.") +public class PersistenceMaintenanceLogCommand extends BaseMaintenanceCommand { + @Override + public Integer call() { + checkInMemory(); + + var infos = maintenanceService.maintenanceRunLog(); + var out = spec.commandLine().getOut(); + + out.println("Recorded Polaris persistence maintenance runs:"); + if (!infos.isEmpty()) { + infos.forEach(i -> printRunInformation(i, false)); + } else { + out.println("(none)"); + } + + return 0; + } +} diff --git a/runtime/admin/src/main/java/org/apache/polaris/admintool/maintenance/PersistenceMaintenanceRunCommand.java b/runtime/admin/src/main/java/org/apache/polaris/admintool/maintenance/PersistenceMaintenanceRunCommand.java new file mode 100644 index 0000000000..ae61b8a158 --- /dev/null +++ b/runtime/admin/src/main/java/org/apache/polaris/admintool/maintenance/PersistenceMaintenanceRunCommand.java @@ -0,0 +1,57 @@ +/* + * 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.polaris.admintool.maintenance; + +import jakarta.inject.Inject; +import org.apache.polaris.persistence.nosql.maintenance.api.MaintenanceService; +import picocli.CommandLine; + +@CommandLine.Command( + name = "run", + mixinStandardHelpOptions = true, + description = {"Run Polaris persistence maintenance."}) +public class PersistenceMaintenanceRunCommand extends BaseMaintenanceCommand { + @Inject MaintenanceService maintenanceService; + + // TODO once there's a fully-tested tasks "client" and 'MaintenanceTaskBehavior', _running_ + // maintenance should be directed through the tasks-API, giving users the option to run + // maintenance "locally" in the admin client or on any polaris server instance, while also + // ensuring (via the tasks framework) that only one maintenance run is active at any time. + + @Override + public Integer call() { + checkInMemory(); + + printMaintenanceConfig(); + var runSpec = printRealmStates(); + + var out = spec.commandLine().getOut(); + out.println(); + out.println("Starting maintenance run..."); + out.println( + "This can run for quite some time, messages may be not be printed immediately, stay patient..."); + out.println(); + + var runInformation = maintenanceService.performMaintenance(runSpec); + + printRunInformation(runInformation, false); + + return 0; + } +} diff --git a/runtime/admin/src/main/resources/application.properties b/runtime/admin/src/main/resources/application.properties index 29229ebece..049de85503 100644 --- a/runtime/admin/src/main/resources/application.properties +++ b/runtime/admin/src/main/resources/application.properties @@ -40,7 +40,25 @@ quarkus.rds.sync-client.type=apache # ---- Runtime Configuration ---- # Below are default values for properties that can be changed in runtime. +# Available types: +# - in-memory - InMemoryPolarisMetaStoreManagerFactory +# - in-memory-atomic - InMemoryAtomicOperationMetaStoreManagerFactory +# - relational-jdbc - JdbcMetaStoreManagerFactory +# - nosql - NoSQL persistence backend, define the backend type via 'polaris.persistence.backend.type' polaris.persistence.type=relational-jdbc + +# Database backend for 'persistence' persistence-type +# Available backends: +# - InMemory - for testing purposes +# - MongoDb - configure the via the Quarkus extension +polaris.persistence.backend.type=InMemory + +## MongoDB version store specific configuration +quarkus.mongodb.database=polaris +quarkus.mongodb.metrics.enabled=true +#quarkus.mongodb.connection-string=mongodb://localhost:27017 +quarkus.mongodb.devservices.enabled=false + #quarkus.datasource.active=false #quarkus.datasource.url= #quarkus.datasource.username= @@ -50,10 +68,13 @@ quarkus.datasource.devservices.enabled=false quarkus.arc.ignored-split-packages=\ org.apache.polaris.admintool.config,\ + org.apache.polaris.admintool.maintenance,\ org.apache.polaris.admintool ## Quarkus required setting for third party indexing # fixed at build-time +quarkus.index-dependency.agrona.group-id=org.agrona +quarkus.index-dependency.agrona.artifact-id=agrona quarkus.index-dependency.avro.group-id=org.apache.avro quarkus.index-dependency.avro.artifact-id=avro quarkus.index-dependency.guava.group-id=com.google.guava diff --git a/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceInMemoryBootstrapCommandTest.java b/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceInMemoryBootstrapCommandTest.java new file mode 100644 index 0000000000..e4ea3368a1 --- /dev/null +++ b/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceInMemoryBootstrapCommandTest.java @@ -0,0 +1,25 @@ +/* + * 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.polaris.admintool.persistence; + +import io.quarkus.test.junit.TestProfile; +import org.apache.polaris.admintool.BootstrapCommandTestBase; + +@TestProfile(PersistenceInMemoryProfile.class) +class PersistenceInMemoryBootstrapCommandTest extends BootstrapCommandTestBase {} diff --git a/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceInMemoryProfile.java b/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceInMemoryProfile.java new file mode 100644 index 0000000000..e238058774 --- /dev/null +++ b/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceInMemoryProfile.java @@ -0,0 +1,30 @@ +/* + * 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.polaris.admintool.persistence; + +import io.quarkus.test.junit.QuarkusTestProfile; +import java.util.Map; + +public class PersistenceInMemoryProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "polaris.persistence.type", "nosql", "polaris.persistence.backend.type", "InMemory"); + } +} diff --git a/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceInMemoryPurgeCommandTest.java b/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceInMemoryPurgeCommandTest.java new file mode 100644 index 0000000000..d590c226d4 --- /dev/null +++ b/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceInMemoryPurgeCommandTest.java @@ -0,0 +1,38 @@ +/* + * 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.polaris.admintool.persistence; + +import com.google.common.collect.ImmutableMap; +import io.quarkus.test.junit.TestProfile; +import java.util.Map; +import org.apache.polaris.admintool.PurgeCommandTestBase; + +@TestProfile(PersistenceInMemoryPurgeCommandTest.Profile.class) +class PersistenceInMemoryPurgeCommandTest extends PurgeCommandTestBase { + + public static class Profile extends PersistenceInMemoryProfile { + @Override + public Map getConfigOverrides() { + return ImmutableMap.builder() + .putAll(super.getConfigOverrides()) + .put("pre-bootstrap", "true") + .build(); + } + } +} diff --git a/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceMongoBootstrapCommandTest.java b/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceMongoBootstrapCommandTest.java new file mode 100644 index 0000000000..24d3558ef5 --- /dev/null +++ b/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceMongoBootstrapCommandTest.java @@ -0,0 +1,25 @@ +/* + * 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.polaris.admintool.persistence; + +import io.quarkus.test.junit.TestProfile; +import org.apache.polaris.admintool.BootstrapCommandTestBase; + +@TestProfile(PersistenceMongoProfile.class) +class PersistenceMongoBootstrapCommandTest extends BootstrapCommandTestBase {} diff --git a/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceMongoProfile.java b/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceMongoProfile.java new file mode 100644 index 0000000000..d4a33b0385 --- /dev/null +++ b/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceMongoProfile.java @@ -0,0 +1,37 @@ +/* + * 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.polaris.admintool.persistence; + +import io.quarkus.test.junit.QuarkusTestProfile; +import java.util.List; +import java.util.Map; +import org.apache.polaris.admintool.MongoTestResourceLifecycleManager; + +public class PersistenceMongoProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "polaris.persistence.type", "nosql", "polaris.persistence.backend.type", "MongoDb"); + } + + @Override + public List testResources() { + return List.of(new TestResourceEntry(MongoTestResourceLifecycleManager.class)); + } +} diff --git a/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceMongoPurgeCommandTest.java b/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceMongoPurgeCommandTest.java new file mode 100644 index 0000000000..31069847bf --- /dev/null +++ b/runtime/admin/src/test/java/org/apache/polaris/admintool/persistence/PersistenceMongoPurgeCommandTest.java @@ -0,0 +1,38 @@ +/* + * 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.polaris.admintool.persistence; + +import com.google.common.collect.ImmutableMap; +import io.quarkus.test.junit.TestProfile; +import java.util.Map; +import org.apache.polaris.admintool.PurgeCommandTestBase; + +@TestProfile(PersistenceMongoPurgeCommandTest.Profile.class) +class PersistenceMongoPurgeCommandTest extends PurgeCommandTestBase { + + public static class Profile extends PersistenceMongoProfile { + @Override + public Map getConfigOverrides() { + return ImmutableMap.builder() + .putAll(super.getConfigOverrides()) + .put("pre-bootstrap", "true") + .build(); + } + } +} diff --git a/runtime/admin/src/testFixtures/java/org/apache/polaris/admintool/MongoTestResourceLifecycleManager.java b/runtime/admin/src/testFixtures/java/org/apache/polaris/admintool/MongoTestResourceLifecycleManager.java new file mode 100644 index 0000000000..c42d47a20b --- /dev/null +++ b/runtime/admin/src/testFixtures/java/org/apache/polaris/admintool/MongoTestResourceLifecycleManager.java @@ -0,0 +1,56 @@ +/* + * 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.polaris.admintool; + +import io.quarkus.test.common.DevServicesContext; +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import java.util.Map; +import org.apache.polaris.persistence.nosql.mongodb.MongoDbBackendTestFactory; + +public class MongoTestResourceLifecycleManager + implements QuarkusTestResourceLifecycleManager, DevServicesContext.ContextAware { + + private MongoDbBackendTestFactory mongoDbBackendTestFactory; + private DevServicesContext context; + + @Override + public void setIntegrationTestContext(DevServicesContext context) { + this.context = context; + } + + @Override + @SuppressWarnings("resource") + public Map start() { + mongoDbBackendTestFactory = new MongoDbBackendTestFactory(); + mongoDbBackendTestFactory.start(context.containerNetworkId()); + return Map.of( + "quarkus.mongodb.connection-string", mongoDbBackendTestFactory.connectionString()); + } + + @Override + public void stop() { + if (mongoDbBackendTestFactory != null) { + try { + mongoDbBackendTestFactory.stop(); + } finally { + mongoDbBackendTestFactory = null; + } + } + } +} diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties index 6cccec578b..516f776ea2 100644 --- a/runtime/defaults/src/main/resources/application.properties +++ b/runtime/defaults/src/main/resources/application.properties @@ -119,9 +119,17 @@ polaris.features."SUPPORTED_CATALOG_CONNECTION_TYPES"=["ICEBERG_REST"] # realm overrides # polaris.features.realm-overrides."my-realm"."SKIP_CREDENTIAL_SUBSCOPING_INDIRECTION"=true -# polaris.persistence.type=in-memory-atomic +# Available types: +# - in-memory - InMemoryPolarisMetaStoreManagerFactory +# - in-memory-atomic - InMemoryAtomicOperationMetaStoreManagerFactory +# - nosql - NoSQL persistence backend, define the backend type via 'polaris.persistence.backend.type' +# - relational-jdbc polaris.persistence.type=in-memory -# polaris.persistence.type=relational-jdbc +# Database backend for 'persistence' persistence-type +# Available backends: +# - InMemory - for testing purposes +# - MongoDb - configure the via the Quarkus extension +polaris.persistence.backend.type=InMemory polaris.secrets-manager.type=in-memory # if set to true it will try to start localstack at build and run time for the local environment @@ -246,6 +254,11 @@ polaris.credential-manager.type=default polaris.iceberg-metrics.reporting.type=default # Set to INFO if you want to see iceberg metric reports logged quarkus.log.category."org.apache.polaris.service.reporting".level=OFF +## MongoDB version store specific configuration +quarkus.mongodb.database=polaris +quarkus.mongodb.metrics.enabled=true +#quarkus.mongodb.connection-string=mongodb://localhost:27017 +quarkus.mongodb.devservices.enabled=false quarkus.arc.ignored-split-packages=\ org.apache.polaris.service.catalog.api,\ @@ -274,6 +287,8 @@ quarkus.arc.ignored-split-packages=\ ## Quarkus required setting for third party indexing # fixed at build-time +quarkus.index-dependency.agrona.group-id=org.agrona +quarkus.index-dependency.agrona.artifact-id=agrona quarkus.index-dependency.avro.group-id=org.apache.avro quarkus.index-dependency.avro.artifact-id=avro quarkus.index-dependency.guava.group-id=com.google.guava diff --git a/runtime/service/build.gradle.kts b/runtime/service/build.gradle.kts index c0f462982a..8a5c4a472a 100644 --- a/runtime/service/build.gradle.kts +++ b/runtime/service/build.gradle.kts @@ -39,6 +39,11 @@ dependencies { compileOnly(project(":polaris-immutables")) annotationProcessor(project(":polaris-immutables", configuration = "processor")) + runtimeOnly(project(":polaris-persistence-nosql-metastore")) + runtimeOnly(project(":polaris-persistence-nosql-cdi-quarkus")) + runtimeOnly(project(":polaris-persistence-nosql-cdi-quarkus-distcache")) + runtimeOnly(project(":polaris-persistence-nosql-maintenance-impl")) + implementation(platform(libs.iceberg.bom)) implementation("org.apache.iceberg:iceberg-api") implementation("org.apache.iceberg:iceberg-core") @@ -150,7 +155,11 @@ dependencies { testImplementation(platform(libs.testcontainers.bom)) testImplementation("org.testcontainers:testcontainers") - testImplementation("org.testcontainers:postgresql") + testImplementation("org.testcontainers:testcontainers-postgresql") + + testImplementation(project(":polaris-persistence-nosql-api")) + testImplementation(testFixtures(project(":polaris-persistence-nosql-api"))) + testImplementation(project(":polaris-persistence-nosql-impl")) testFixturesImplementation(project(":polaris-core")) testFixturesImplementation(project(":polaris-api-management-model")) diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ApplicationPersistenceIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ApplicationPersistenceIT.java new file mode 100644 index 0000000000..7c957556e0 --- /dev/null +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ApplicationPersistenceIT.java @@ -0,0 +1,27 @@ +/* + * 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.polaris.service.it; + +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.quarkus.test.junit.TestProfile; +import org.apache.polaris.service.it.test.PolarisApplicationIntegrationTest; + +@QuarkusIntegrationTest +@TestProfile(value = PersistenceTesting.PersistenceInMemoryProfile.class) +public class ApplicationPersistenceIT extends PolarisApplicationIntegrationTest {} diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ManagementServicePersistenceIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ManagementServicePersistenceIT.java new file mode 100644 index 0000000000..7a722aa5f2 --- /dev/null +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ManagementServicePersistenceIT.java @@ -0,0 +1,27 @@ +/* + * 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.polaris.service.it; + +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.quarkus.test.junit.TestProfile; +import org.apache.polaris.service.it.test.PolarisManagementServiceIntegrationTest; + +@QuarkusIntegrationTest +@TestProfile(value = PersistenceTesting.PersistenceInMemoryProfile.class) +public class ManagementServicePersistenceIT extends PolarisManagementServiceIntegrationTest {} diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/PersistenceTesting.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/PersistenceTesting.java new file mode 100644 index 0000000000..76b99e8ebc --- /dev/null +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/PersistenceTesting.java @@ -0,0 +1,34 @@ +/* + * 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.polaris.service.it; + +import io.quarkus.test.junit.QuarkusTestProfile; +import java.util.Map; + +public final class PersistenceTesting { + private PersistenceTesting() {} + + public static class PersistenceInMemoryProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "polaris.persistence.type", "nosql", "polaris.persistence.backend.type", "InMemory"); + } + } +} diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/PolarisRestCatalogMinIOIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/PolarisRestCatalogMinIOIT.java index 7b1e38b83e..48a19d8aed 100644 --- a/runtime/service/src/intTest/java/org/apache/polaris/service/it/PolarisRestCatalogMinIOIT.java +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/PolarisRestCatalogMinIOIT.java @@ -42,9 +42,9 @@ @ExtendWith(PolarisIntegrationTestExtension.class) public class PolarisRestCatalogMinIOIT extends PolarisRestCatalogIntegrationBase { - private static final String BUCKET_URI_PREFIX = "/minio-test-polaris"; - private static final String MINIO_ACCESS_KEY = "test-ak-123-polaris"; - private static final String MINIO_SECRET_KEY = "test-sk-123-polaris"; + protected static final String BUCKET_URI_PREFIX = "/minio-test-polaris"; + protected static final String MINIO_ACCESS_KEY = "test-ak-123-polaris"; + protected static final String MINIO_SECRET_KEY = "test-sk-123-polaris"; public static class Profile implements QuarkusTestProfile { diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/RestCatalogPersistenceIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/RestCatalogPersistenceIT.java new file mode 100644 index 0000000000..858b3de18e --- /dev/null +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/RestCatalogPersistenceIT.java @@ -0,0 +1,40 @@ +/* + * 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.polaris.service.it; + +import com.google.common.collect.ImmutableMap; +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.quarkus.test.junit.TestProfile; +import java.util.Map; + +@QuarkusIntegrationTest +@TestProfile(value = RestCatalogPersistenceIT.Profile.class) +public class RestCatalogPersistenceIT extends PolarisRestCatalogMinIOIT { + public static class Profile extends PersistenceTesting.PersistenceInMemoryProfile { + @Override + public Map getConfigOverrides() { + return ImmutableMap.builder() + .putAll(super.getConfigOverrides()) + .put("polaris.storage.aws.access-key", MINIO_ACCESS_KEY) + .put("polaris.storage.aws.secret-key", MINIO_SECRET_KEY) + .put("polaris.features.\"SKIP_CREDENTIAL_SUBSCOPING_INDIRECTION\"", "false") + .build(); + } + } +} diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalog.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalog.java index e8f5402b1b..191638fb43 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalog.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalog.java @@ -984,12 +984,15 @@ private void validateNoLocationOverlap( // Create a fake IcebergTableLikeEntity to check for overlap, since no real entity // has been created yet. + var lastNamespace = resolvedNamespace.getLast(); IcebergTableLikeEntity virtualEntity = IcebergTableLikeEntity.of( new PolarisEntity.Builder() .setType(PolarisEntityType.TABLE_LIKE) .setSubType(PolarisEntitySubType.ICEBERG_TABLE) - .setParentId(resolvedNamespace.getLast().getId()) + .setParentId(lastNamespace.getId()) + .setCatalogId(lastNamespace.getCatalogId()) + .setParentId(lastNamespace.getId()) .setProperties(Map.of(PolarisEntityConstants.ENTITY_BASE_LOCATION, location)) .build()); diff --git a/runtime/service/src/main/java/org/apache/polaris/service/persistence/PersistenceConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/persistence/PersistenceConfiguration.java index 54e2b61284..1f16cea11a 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/persistence/PersistenceConfiguration.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/persistence/PersistenceConfiguration.java @@ -31,7 +31,7 @@ public interface PersistenceConfiguration { */ String type(); - @WithDefault("in-memory") + @WithDefault("in-memory,nosql") Set autoBootstrapTypes(); default boolean isAutoBootstrap() { diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/Profiles.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/Profiles.java index c4231ac26d..1c33d11edc 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/Profiles.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/Profiles.java @@ -19,12 +19,19 @@ package org.apache.polaris.service.catalog; +import com.google.common.collect.ImmutableMap; import io.quarkus.test.junit.QuarkusTestProfile; import java.util.Map; public final class Profiles { private Profiles() {} + public static final Map NOSQL_IN_MEM = + ImmutableMap.builder() + .put("polaris.persistence.type", "nosql") + .put("polaris.persistence.backend.type", "InMemory") + .build(); + public static class DefaultProfile implements QuarkusTestProfile { @Override public Map getConfigOverrides() { @@ -41,4 +48,14 @@ public Map getConfigOverrides() { "true"); } } + + public static class DefaultNoSqlProfile extends DefaultProfile { + @Override + public Map getConfigOverrides() { + return ImmutableMap.builder() + .putAll(super.getConfigOverrides()) + .putAll(NOSQL_IN_MEM) + .build(); + } + } } diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/generic/PolarisGenericTableCatalogNoSqlInMemTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/generic/PolarisGenericTableCatalogNoSqlInMemTest.java new file mode 100644 index 0000000000..ab29415e61 --- /dev/null +++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/generic/PolarisGenericTableCatalogNoSqlInMemTest.java @@ -0,0 +1,39 @@ +/* + * 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.polaris.service.catalog.generic; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import java.util.List; +import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet; +import org.apache.polaris.service.catalog.Profiles; + +@QuarkusTest +@TestProfile(Profiles.DefaultNoSqlProfile.class) +public class PolarisGenericTableCatalogNoSqlInMemTest + extends AbstractPolarisGenericTableCatalogTest { + @Override + protected void bootstrapRealm(String realmName) { + metaStoreManagerFactory + .bootstrapRealms( + List.of(realmName), + RootCredentialsSet.fromList(List.of(realmName + ",aClientId,aSecret"))) + .get(realmName); + } +} diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogNoSqlInMemTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogNoSqlInMemTest.java new file mode 100644 index 0000000000..536c17c038 --- /dev/null +++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogNoSqlInMemTest.java @@ -0,0 +1,64 @@ +/* + * 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.polaris.service.catalog.iceberg; + +import static org.apache.polaris.service.catalog.Profiles.NOSQL_IN_MEM; + +import com.google.common.collect.ImmutableMap; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import java.util.List; +import java.util.Map; +import org.apache.polaris.core.PolarisDiagnostics; +import org.apache.polaris.core.config.RealmConfig; +import org.apache.polaris.core.persistence.PolarisMetaStoreManager; +import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet; +import org.apache.polaris.core.persistence.cache.EntityCache; + +@QuarkusTest +@TestProfile(IcebergCatalogNoSqlInMemTest.Profile.class) +public class IcebergCatalogNoSqlInMemTest extends AbstractIcebergCatalogTest { + + public static class Profile extends AbstractIcebergCatalogTest.Profile { + @Override + public Map getConfigOverrides() { + return ImmutableMap.builder() + .putAll(super.getConfigOverrides()) + .putAll(NOSQL_IN_MEM) + .build(); + } + } + + @Override + protected void bootstrapRealm(String realmName) { + metaStoreManagerFactory + .bootstrapRealms( + List.of(realmName), + RootCredentialsSet.fromList(List.of(realmName + ",aClientId,aSecret"))) + .get(realmName); + } + + @Override + protected EntityCache createEntityCache( + PolarisDiagnostics diagnostics, + RealmConfig realmConfig, + PolarisMetaStoreManager metaStoreManager) { + return null; + } +} diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergViewCatalogNoSqlInMemTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergViewCatalogNoSqlInMemTest.java new file mode 100644 index 0000000000..84fe626c81 --- /dev/null +++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergViewCatalogNoSqlInMemTest.java @@ -0,0 +1,52 @@ +/* + * 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.polaris.service.catalog.iceberg; + +import static org.apache.polaris.service.catalog.Profiles.NOSQL_IN_MEM; + +import com.google.common.collect.ImmutableMap; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import java.util.List; +import java.util.Map; +import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet; + +@QuarkusTest +@TestProfile(IcebergViewCatalogNoSqlInMemTest.Profile.class) +public class IcebergViewCatalogNoSqlInMemTest extends AbstractIcebergCatalogViewTest { + + public static class Profile extends AbstractIcebergCatalogViewTest.Profile { + @Override + public Map getConfigOverrides() { + return ImmutableMap.builder() + .putAll(super.getConfigOverrides()) + .putAll(NOSQL_IN_MEM) + .build(); + } + } + + @Override + protected void bootstrapRealm(String realmName) { + metaStoreManagerFactory + .bootstrapRealms( + List.of(realmName), + RootCredentialsSet.fromList(List.of(realmName + ",aClientId,aSecret"))) + .get(realmName); + } +} diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/policy/PolicyCatalogNoSqlInMemTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/policy/PolicyCatalogNoSqlInMemTest.java new file mode 100644 index 0000000000..1231e08ffd --- /dev/null +++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/policy/PolicyCatalogNoSqlInMemTest.java @@ -0,0 +1,38 @@ +/* + * 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.polaris.service.catalog.policy; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import java.util.List; +import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet; +import org.apache.polaris.service.catalog.Profiles; + +@QuarkusTest +@TestProfile(Profiles.DefaultNoSqlProfile.class) +public class PolicyCatalogNoSqlInMemTest extends AbstractPolicyCatalogTest { + @Override + protected void bootstrapRealm(String realmName) { + metaStoreManagerFactory + .bootstrapRealms( + List.of(realmName), + RootCredentialsSet.fromList(List.of(realmName + ",aClientId,aSecret"))) + .get(realmName); + } +} diff --git a/runtime/service/src/test/java/org/apache/polaris/service/distcache/HttpTestServer.java b/runtime/service/src/test/java/org/apache/polaris/service/distcache/HttpTestServer.java new file mode 100644 index 0000000000..d89ef619a4 --- /dev/null +++ b/runtime/service/src/test/java/org/apache/polaris/service/distcache/HttpTestServer.java @@ -0,0 +1,70 @@ +/* + * 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.polaris.service.distcache; + +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; + +/** HTTP test server. */ +public class HttpTestServer implements AutoCloseable { + private final HttpServer server; + + public HttpTestServer(String context, HttpHandler handler) throws IOException { + this(new InetSocketAddress("localhost", 0), context, handler); + } + + public HttpTestServer(InetSocketAddress bind, String context, HttpHandler handler) + throws IOException { + HttpHandler safeHandler = + exchange -> { + try { + handler.handle(exchange); + } catch (RuntimeException | Error e) { + exchange.sendResponseHeaders(503, 0); + throw e; + } + }; + server = HttpServer.create(bind, 0); + server.createContext(context, safeHandler); + server.setExecutor(null); + + server.start(); + } + + public InetSocketAddress getAddress() { + return server.getAddress(); + } + + public URI getUri() { + return URI.create( + "http://" + + getAddress().getAddress().getHostAddress() + + ":" + + getAddress().getPort() + + "/"); + } + + @Override + public void close() { + server.stop(0); + } +} diff --git a/runtime/service/src/test/java/org/apache/polaris/service/distcache/TestPersistenceDistCacheInvalidationsIntegration.java b/runtime/service/src/test/java/org/apache/polaris/service/distcache/TestPersistenceDistCacheInvalidationsIntegration.java new file mode 100644 index 0000000000..a0d12cda15 --- /dev/null +++ b/runtime/service/src/test/java/org/apache/polaris/service/distcache/TestPersistenceDistCacheInvalidationsIntegration.java @@ -0,0 +1,213 @@ +/* + * 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.polaris.service.distcache; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static java.lang.String.format; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Map.entry; +import static java.util.Objects.requireNonNull; +import static org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.CacheInvalidationEvictObj.cacheInvalidationEvictObj; +import static org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.CacheInvalidationEvictReference.cacheInvalidationEvictReference; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.list; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import jakarta.inject.Inject; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.RealmPersistenceFactory; +import org.apache.polaris.persistence.nosql.api.SystemPersistence; +import org.apache.polaris.persistence.nosql.api.cache.CacheBackend; +import org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations; +import org.apache.polaris.persistence.nosql.api.cache.CacheInvalidations.CacheInvalidation; +import org.apache.polaris.persistence.nosql.api.obj.SimpleTestObj; +import org.apache.polaris.service.catalog.iceberg.AbstractIcebergCatalogTest; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +@QuarkusTest +@TestProfile(TestPersistenceDistCacheInvalidationsIntegration.Profile.class) +// Need two IPs in this test. macOS doesn't allow binding to arbitrary 127.x.x.x addresses. +@EnabledOnOs(OS.LINUX) +@SuppressWarnings("CdiInjectionPointsInspection") +public class TestPersistenceDistCacheInvalidationsIntegration { + + public static class Profile extends AbstractIcebergCatalogTest.Profile { + @Override + public Map getConfigOverrides() { + return ImmutableMap.builder() + .putAll(super.getConfigOverrides()) + .put("quarkus.management.port", "" + QUARKUS_MANAGEMENT_PORT) + .put("quarkus.management.test-port", "" + QUARKUS_MANAGEMENT_PORT) + .put("quarkus.management.host", "127.0.0.1") + .put("quarkus.management.enabled", "true") + .put("polaris.persistence.type", "nosql") + .put("polaris.persistence.backend.type", "InMemory") + .put( + "polaris.persistence.distributed-cache-invalidations.valid-tokens", "token1," + TOKEN) + .put( + "polaris.persistence.distributed-cache-invalidations.service-names", + "=127.0.0.1,=127.1.2.3") + .build(); + } + } + + static final String TOKEN = "otherToken"; + static final String ENDPOINT = "/polaris-management/cache-coherency"; + + // MUST be constant for test AND service + static final int QUARKUS_MANAGEMENT_PORT = 64321; + + static URI CACHE_INVALIDATIONS_ENDPOINT = + URI.create( + format( + "http://127.0.0.1:%d%s?sender=" + UUID.randomUUID(), + QUARKUS_MANAGEMENT_PORT, + ENDPOINT)); + + SoftAssertions soft; + ObjectMapper mapper; + + @Inject CacheBackend cacheBackend; + + @Inject @SystemPersistence Persistence systemPersistence; + @Inject RealmPersistenceFactory realmPersistenceFactory; + + @BeforeEach + public void before() { + soft = new SoftAssertions(); + mapper = new ObjectMapper(); + } + + @AfterEach + public void after() { + soft.assertAll(); + } + + @Test + public void systemRealm() throws Exception { + sendReceive(systemPersistence); + } + + @Test + public void otherRealm() throws Exception { + var persistence = realmPersistenceFactory.newBuilder().realmId("foo").build(); + sendReceive(persistence); + } + + private void sendReceive(Persistence persistence) throws Exception { + var queue = new LinkedBlockingQueue>(); + try (var ignore = + new HttpTestServer( + new InetSocketAddress("127.1.2.3", QUARKUS_MANAGEMENT_PORT), + ENDPOINT, + exchange -> { + try (InputStream requestBody = exchange.getRequestBody()) { + queue.add( + entry(exchange.getRequestURI(), new String(requestBody.readAllBytes(), UTF_8))); + } catch (Exception e) { + throw new RuntimeException(e); + } + exchange.sendResponseHeaders(204, -1); + exchange.getResponseBody().close(); + })) { + + var obj = SimpleTestObj.builder().id(persistence.generateId()).text("test").build(); + persistence.write(obj, SimpleTestObj.class); + + // verify that "we" received the invalidation for the obj + var invalidation = queue.poll(1, TimeUnit.MINUTES); + var uri = requireNonNull(invalidation).getKey(); + soft.assertThat(uri.getRawQuery()).startsWith("sender="); + var invalidations = mapper.readValue(invalidation.getValue(), CacheInvalidations.class); + soft.assertThat(invalidations) + .extracting(CacheInvalidations::invalidations, list(CacheInvalidation.class)) + .containsExactly(cacheInvalidationEvictObj(persistence.realmId(), objRef(obj))); + + // verify that "the service" processes an invalidation + soft.assertThat(cacheBackend.get(persistence.realmId(), objRef(obj))).isNotNull(); + send(invalidations); + awaitCondition( + () -> assertThat(cacheBackend.get(persistence.realmId(), objRef(obj))).isNull()); + + // reference + + var refName = "foo-ref"; + persistence.createReference(refName, Optional.empty()); + + // verify that "we" received the invalidation for the reference + invalidation = queue.poll(1, TimeUnit.MINUTES); + uri = requireNonNull(invalidation).getKey(); + soft.assertThat(uri.getRawQuery()).startsWith("sender="); + invalidations = mapper.readValue(invalidation.getValue(), CacheInvalidations.class); + soft.assertThat(invalidations) + .extracting(CacheInvalidations::invalidations, list(CacheInvalidation.class)) + .containsExactly(cacheInvalidationEvictReference(persistence.realmId(), refName)); + + // verify that "the service" processes an invalidation + soft.assertThat(cacheBackend.getReference(persistence.realmId(), refName)).isNotNull(); + send(invalidations); + awaitCondition( + () -> assertThat(cacheBackend.get(persistence.realmId(), objRef(obj))).isNull()); + } + } + + @SuppressWarnings("BusyWait") + private void awaitCondition(Runnable test) throws Exception { + var tEnd = System.nanoTime() + TimeUnit.MINUTES.toNanos(1); + while (System.nanoTime() < tEnd) { + try { + test.run(); + return; + } catch (AssertionError e) { + if (System.nanoTime() > tEnd) { + throw e; + } + } + Thread.sleep(1); + } + } + + private void send(CacheInvalidations invalidations) throws Exception { + var urlConn = CACHE_INVALIDATIONS_ENDPOINT.toURL().openConnection(); + urlConn.setDoOutput(true); + urlConn.setRequestProperty("Content-Type", APPLICATION_JSON); + urlConn.setRequestProperty("Polaris-Cache-Invalidation-Token", TOKEN); + try (var out = urlConn.getOutputStream()) { + out.write(mapper.writeValueAsBytes(invalidations)); + } + urlConn.getInputStream().readAllBytes(); + } +} diff --git a/runtime/test-common/build.gradle.kts b/runtime/test-common/build.gradle.kts index b728ea17ac..a288b3d409 100644 --- a/runtime/test-common/build.gradle.kts +++ b/runtime/test-common/build.gradle.kts @@ -40,7 +40,7 @@ dependencies { implementation(platform(libs.testcontainers.bom)) implementation("org.testcontainers:testcontainers") - implementation("org.testcontainers:postgresql") + implementation("org.testcontainers:testcontainers-postgresql") implementation(libs.testcontainers.keycloak) { exclude(group = "org.keycloak", module = "keycloak-admin-client") diff --git a/tools/config-docs/site/build.gradle.kts b/tools/config-docs/site/build.gradle.kts index e4caa94625..09de90270f 100644 --- a/tools/config-docs/site/build.gradle.kts +++ b/tools/config-docs/site/build.gradle.kts @@ -26,6 +26,14 @@ description = "Polaris site - reference docs" val genProjectPaths = listOf( ":polaris-async-api", + ":polaris-nodes-api", + ":polaris-persistence-nosql-api", + ":polaris-persistence-nosql-impl", + ":polaris-persistence-nosql-cdi-quarkus", + ":polaris-persistence-nosql-cdi-quarkus-distcache", + ":polaris-persistence-nosql-maintenance-api", + ":polaris-persistence-nosql-mongodb", + ":polaris-persistence-nosql-types", ":polaris-runtime-service", )