diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts index bb667fe87c..0564351ff1 100644 --- a/bom/build.gradle.kts +++ b/bom/build.gradle.kts @@ -49,6 +49,10 @@ dependencies { api(project(":polaris-nodes-spi")) api(project(":polaris-persistence-nosql-api")) + api(project(":polaris-persistence-nosql-impl")) + api(project(":polaris-persistence-nosql-testextension")) + + api(project(":polaris-persistence-nosql-inmemory")) api(project(":polaris-config-docs-annotations")) api(project(":polaris-config-docs-generator")) diff --git a/build.gradle.kts b/build.gradle.kts index 0b70dee989..359ff00b9d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -92,6 +92,11 @@ tasks.named("rat").configure { excludes.add("logs/**") excludes.add("**/*.lock") + // Binary files + excludes.add( + "persistence/nosql/persistence/index/src/testFixtures/resources/org/apache/polaris/persistence/indexes/words.gz" + ) + // Polaris service startup banner excludes.add("runtime/service/src/**/banner.txt") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c5365a223c..7867495354 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,7 +39,7 @@ swagger = "1.6.16" # If a dependency is removed, check whether the LICENSE and/or NOTICE files need to be adopted # (aka mention of the dependency removed). # -quarkus-amazon-services-bom = { module = "io.quarkus.platform:quarkus-amazon-services-bom", version.ref="quarkus" } +agrona = { module = "org.agrona:agrona", version = "2.2.4" } antlr4-runtime = { module = "org.antlr:antlr4-runtime", version.strictly = "4.9.3" } # spark integration tests apache-httpclient5 = { module = "org.apache.httpcomponents.client5:httpclient5", version = "5.5.1" } assertj-core = { module = "org.assertj:assertj-core", version = "3.27.6" } @@ -86,11 +86,13 @@ microprofile-fault-tolerance-api = { module = "org.eclipse.microprofile.fault-to mockito-core = { module = "org.mockito:mockito-core", version = "5.20.0" } mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version = "5.20.0" } opentelemetry-bom = { module = "io.opentelemetry:opentelemetry-bom", version = "1.55.0" } +opentelemetry-instrumentation-bom-alpha = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha", version= "2.20.1-alpha" } opentelemetry-semconv = { module = "io.opentelemetry.semconv:opentelemetry-semconv", version = "1.37.0" } picocli = { module = "info.picocli:picocli-codegen", version.ref = "picocli" } picocli-codegen = { module = "info.picocli:picocli-codegen", version.ref = "picocli" } postgresql = { module = "org.postgresql:postgresql", version = "42.7.8" } prometheus-metrics-exporter-servlet-jakarta = { module = "io.prometheus:prometheus-metrics-exporter-servlet-jakarta", version = "1.4.2" } +quarkus-amazon-services-bom = { module = "io.quarkus.platform:quarkus-amazon-services-bom", version.ref="quarkus" } quarkus-bom = { module = "io.quarkus.platform:quarkus-bom", version.ref = "quarkus" } scala212-lang-library = { module = "org.scala-lang:scala-library", version.ref = "scala212" } scala212-lang-reflect = { module = "org.scala-lang:scala-reflect", version.ref = "scala212" } diff --git a/gradle/projects.main.properties b/gradle/projects.main.properties index b5c290eaf8..11f74e681b 100644 --- a/gradle/projects.main.properties +++ b/gradle/projects.main.properties @@ -64,4 +64,8 @@ polaris-nodes-impl=persistence/nosql/nodes/impl polaris-nodes-spi=persistence/nosql/nodes/spi # persistence / database agnostic polaris-persistence-nosql-api=persistence/nosql/persistence/api +polaris-persistence-nosql-impl=persistence/nosql/persistence/impl +polaris-persistence-nosql-testextension=persistence/nosql/persistence/testextension polaris-persistence-nosql-varint=persistence/nosql/persistence/varint +# persistence / database specific implementations +polaris-persistence-nosql-inmemory=persistence/nosql/persistence/db/inmemory diff --git a/persistence/nosql/persistence/db/inmemory/build.gradle.kts b/persistence/nosql/persistence/db/inmemory/build.gradle.kts new file mode 100644 index 0000000000..aea21ac97d --- /dev/null +++ b/persistence/nosql/persistence/db/inmemory/build.gradle.kts @@ -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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris NoSQL persistence, in-memory implementation" + +dependencies { + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-persistence-nosql-impl")) + implementation(project(":polaris-idgen-api")) + + 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")) + + implementation(platform(libs.micrometer.bom)) + implementation("io.micrometer:micrometer-core") + compileOnly(platform(libs.opentelemetry.instrumentation.bom.alpha)) + compileOnly("io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations") + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + compileOnly(libs.smallrye.config.core) + + compileOnly(platform(libs.quarkus.bom)) + compileOnly("io.quarkus:quarkus-core") + + testFixturesCompileOnly(platform(libs.jackson.bom)) + testFixturesCompileOnly("com.fasterxml.jackson.core:jackson-annotations") + + testFixturesCompileOnly(libs.jakarta.annotation.api) + testFixturesCompileOnly(libs.jakarta.validation.api) + + testFixturesCompileOnly(project(":polaris-immutables")) + testFixturesAnnotationProcessor(project(":polaris-immutables", configuration = "processor")) + + testFixturesApi(testFixtures(project(":polaris-persistence-nosql-impl"))) + testFixturesApi(project(":polaris-persistence-nosql-testextension")) +} diff --git a/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackend.java b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackend.java new file mode 100644 index 0000000000..eb39815e35 --- /dev/null +++ b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackend.java @@ -0,0 +1,288 @@ +/* + * 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.inmemory; + +import static org.apache.polaris.persistence.nosql.api.backend.PersistId.persistId; +import static org.apache.polaris.persistence.nosql.inmemory.ObjKey.objKey; +import static org.apache.polaris.persistence.nosql.inmemory.RefKey.refKey; + +import com.google.common.collect.Maps; +import jakarta.annotation.Nonnull; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ThreadLocalRandom; +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.exceptions.ReferenceNotFoundException; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.ref.Reference; +import org.apache.polaris.persistence.nosql.impl.PersistenceImplementation; + +final class InMemoryBackend implements Backend { + + /** + * For testing purposes, add a random sleep within the given bound in milliseconds for each + * operation. This value can be useful when debugging concurrency issues. + */ + private static final int RANDOM_SLEEP_BOUND = + Integer.getInteger("x-polaris.persistence.inmemory.random.sleep-bound", 0); + + final ConcurrentMap refs = new ConcurrentHashMap<>(); + final ConcurrentMap objs = new ConcurrentHashMap<>(); + + @Override + @Nonnull + public String type() { + return InMemoryBackendFactory.NAME; + } + + @Override + public boolean supportsRealmDeletion() { + return true; + } + + @Override + public void close() {} + + @Nonnull + @Override + public Persistence newPersistence( + Function backendWrapper, + @Nonnull PersistenceParams persistenceParams, + String realmId, + MonotonicClock monotonicClock, + IdGenerator idGenerator) { + return new PersistenceImplementation( + backendWrapper.apply(this), persistenceParams, realmId, monotonicClock, idGenerator); + } + + @Override + public Optional setupSchema() { + return Optional.of("FOR LOCAL TESTING ONLY, NO INFORMATION WILL BE PERSISTED!"); + } + + @Override + public void deleteRealms(Set realmIds) { + objs.entrySet().removeIf(e -> realmIds.contains(e.getKey().realmId())); + refs.entrySet().removeIf(e -> realmIds.contains(e.getKey().realmId())); + } + + @Override + public void batchDeleteRefs(Map> realmRefs) { + realmRefs.forEach( + (realmId, refNames) -> refNames.forEach(ref -> refs.remove(refKey(realmId, ref)))); + } + + @Override + public void batchDeleteObjs(Map> realmObjs) { + realmObjs.forEach( + ((realmId, objIds) -> + objIds.forEach(obj -> objs.remove(objKey(realmId, obj.id(), obj.part()))))); + } + + @Override + public void scanBackend( + @Nonnull ReferenceScanCallback referenceConsumer, @Nonnull ObjScanCallback objConsumer) { + refs.forEach( + (key, ref) -> referenceConsumer.call(key.realmId(), key.name(), ref.createdAtMicros())); + objs.forEach( + (key, serObj) -> + objConsumer.call( + key.realmId(), + serObj.type(), + persistId(key.id(), key.part()), + serObj.createdAtMicros())); + } + + // For testing purposes only + private void randomDelay() { + if (RANDOM_SLEEP_BOUND == 0) { + return; + } + + var i = ThreadLocalRandom.current().nextInt(RANDOM_SLEEP_BOUND); + if (i > 0) { + try { + Thread.sleep(i); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } + + @Override + public boolean createReference(@Nonnull String realmId, @Nonnull Reference newRef) { + randomDelay(); + var key = refKey(realmId, newRef.name()); + return refs.putIfAbsent(key, newRef) == null; + } + + @Override + public void createReferences(@Nonnull String realmId, @Nonnull List newRefs) { + newRefs.forEach(ref -> createReference(realmId, ref)); + } + + @Override + public boolean updateReference( + @Nonnull String realmId, + @Nonnull Reference updatedRef, + @Nonnull Optional expectedPointer) { + randomDelay(); + var key = refKey(realmId, updatedRef.name()); + return refs.compute( + key, + (k, ref) -> { + if (ref == null) { + throw new ReferenceNotFoundException(updatedRef.name()); + } + return ref.pointer().equals(expectedPointer) ? updatedRef : ref; + }) + == updatedRef; + } + + @Override + @Nonnull + public Reference fetchReference(@Nonnull String realmId, @Nonnull String name) { + randomDelay(); + var key = refKey(realmId, name); + var ref = refs.get(key); + if (ref == null) { + throw new ReferenceNotFoundException(name); + } + return ref; + } + + @Override + @Nonnull + public Map fetch(@Nonnull String realmId, @Nonnull Set ids) { + randomDelay(); + var r = Maps.newHashMapWithExpectedSize(ids.size()); + for (var id : ids) { + var key = objKey(realmId, id); + var val = objs.get(key); + if (val != null) { + r.put( + id, + new FetchedObj( + val.type(), + val.createdAtMicros(), + val.versionToken(), + val.serializedValue(), + val.partNum())); + } + } + return r; + } + + @Override + public void write(@Nonnull String realmId, @Nonnull List writes) { + randomDelay(); + for (var write : writes) { + var key = objKey(realmId, write.id(), write.part()); + var val = + new SerializedObj( + write.type(), write.createdAtMicros(), null, write.serialized(), write.partNum()); + objs.put(key, val); + } + } + + @Override + public void delete(@Nonnull String realmId, @Nonnull Set ids) { + randomDelay(); + for (var id : ids) { + var key = objKey(realmId, id.id(), id.part()); + objs.remove(key); + } + } + + @Override + public boolean conditionalInsert( + @Nonnull String realmId, + String objTypeId, + @Nonnull PersistId persistId, + long createdAtMicros, + @Nonnull String versionToken, + @Nonnull byte[] serializedValue) { + randomDelay(); + var key = objKey(realmId, persistId.id(), 0); + var val = new SerializedObj(objTypeId, createdAtMicros, versionToken, serializedValue, 1); + var ex = objs.putIfAbsent(key, val); + return ex == null; + } + + @Override + public boolean conditionalUpdate( + @Nonnull String realmId, + String objTypeId, + @Nonnull PersistId persistId, + long createdAtMicros, + @Nonnull String updateToken, + @Nonnull String expectedToken, + @Nonnull byte[] serializedValue) { + randomDelay(); + var key = objKey(realmId, persistId); + var val = new SerializedObj(objTypeId, createdAtMicros, updateToken, serializedValue, 1); + return objs.computeIfPresent( + key, + (k, ex) -> { + var exToken = ex.versionToken(); + if (!expectedToken.equals(exToken)) { + return ex; + } + return val; + }) + == val; + } + + @Override + public boolean conditionalDelete( + @Nonnull String realmId, @Nonnull PersistId persistId, @Nonnull String expectedToken) { + randomDelay(); + var key = objKey(realmId, persistId); + var r = new boolean[1]; + try { + objs.computeIfPresent( + key, + (k, ex) -> { + var exToken = ex.versionToken(); + if (exToken == null || !exToken.equals(expectedToken)) { + throw new VersionMismatchInternalException(); + } + r[0] = true; + return null; + }); + } catch (VersionMismatchInternalException e) { + // + } + return r[0]; + } + + static final class VersionMismatchInternalException extends RuntimeException {} +} diff --git a/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackendConfig.java b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackendConfig.java new file mode 100644 index 0000000000..b32a9c6dfc --- /dev/null +++ b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackendConfig.java @@ -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. + */ +package org.apache.polaris.persistence.nosql.inmemory; + +public record InMemoryBackendConfig() {} diff --git a/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackendFactory.java b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackendFactory.java new file mode 100644 index 0000000000..9c2c4aaf81 --- /dev/null +++ b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackendFactory.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.inmemory; + +import jakarta.annotation.Nonnull; +import org.apache.polaris.persistence.nosql.api.backend.Backend; +import org.apache.polaris.persistence.nosql.api.backend.BackendFactory; + +public class InMemoryBackendFactory + implements BackendFactory { + public static final String NAME = "InMemory"; + + @Override + @Nonnull + public String name() { + return NAME; + } + + @Override + @Nonnull + public Backend buildBackend(@Nonnull InMemoryBackendConfig backendConfig) { + return new InMemoryBackend(); + } + + @Override + public Class configurationInterface() { + return InMemoryConfiguration.class; + } + + @Override + public InMemoryBackendConfig buildConfiguration(InMemoryConfiguration config) { + return new InMemoryBackendConfig(); + } +} diff --git a/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryConfiguration.java b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryConfiguration.java new file mode 100644 index 0000000000..5131f9c902 --- /dev/null +++ b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryConfiguration.java @@ -0,0 +1,24 @@ +/* + * 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.inmemory; + +import io.smallrye.config.ConfigMapping; + +@ConfigMapping(prefix = "polaris.persistence.backend.inmemory") +public interface InMemoryConfiguration {} diff --git a/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/ObjKey.java b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/ObjKey.java new file mode 100644 index 0000000000..2672080d2c --- /dev/null +++ b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/ObjKey.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.inmemory; + +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.backend.PersistId; +import org.immutables.value.Value; + +@PolarisImmutable +@JsonSerialize(as = ImmutableObjKey.class) +@JsonDeserialize(as = ImmutableObjKey.class) +public interface ObjKey { + static ObjKey objKey(String realmId, long id, int part) { + return ImmutableObjKey.of(realmId, id, part); + } + + static ObjKey objKey(String realmId, PersistId persistId) { + return ImmutableObjKey.of(realmId, persistId.id(), persistId.part()); + } + + @Value.Parameter(order = 1) + String realmId(); + + @Value.Parameter(order = 2) + long id(); + + @Value.Parameter(order = 3) + int part(); +} diff --git a/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/RefKey.java b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/RefKey.java new file mode 100644 index 0000000000..10652b4672 --- /dev/null +++ b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/RefKey.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.inmemory; + +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 = ImmutableRefKey.class) +@JsonDeserialize(as = ImmutableRefKey.class) +public interface RefKey { + @Value.Parameter(order = 1) + String realmId(); + + @Value.Parameter(order = 1) + String name(); + + static RefKey refKey(String realmId, String name) { + return ImmutableRefKey.of(realmId, name); + } +} diff --git a/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/SerializedObj.java b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/SerializedObj.java new file mode 100644 index 0000000000..1a85ea1840 --- /dev/null +++ b/persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/SerializedObj.java @@ -0,0 +1,22 @@ +/* + * 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.inmemory; + +record SerializedObj( + String type, long createdAtMicros, String versionToken, byte[] serializedValue, int partNum) {} diff --git a/persistence/nosql/persistence/db/inmemory/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.backend.BackendFactory b/persistence/nosql/persistence/db/inmemory/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.backend.BackendFactory new file mode 100644 index 0000000000..74284a7e18 --- /dev/null +++ b/persistence/nosql/persistence/db/inmemory/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.backend.BackendFactory @@ -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.inmemory.InMemoryBackendFactory \ No newline at end of file diff --git a/persistence/nosql/persistence/db/inmemory/src/test/java/org/apache/polaris/persistence/nosql/inmemory/TestInMemoryPersistence.java b/persistence/nosql/persistence/db/inmemory/src/test/java/org/apache/polaris/persistence/nosql/inmemory/TestInMemoryPersistence.java new file mode 100644 index 0000000000..6fc7ed8265 --- /dev/null +++ b/persistence/nosql/persistence/db/inmemory/src/test/java/org/apache/polaris/persistence/nosql/inmemory/TestInMemoryPersistence.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.inmemory; + +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.impl.AbstractPersistenceTests; +import org.apache.polaris.persistence.nosql.testextension.BackendSpec; +import org.apache.polaris.persistence.nosql.testextension.PolarisPersistence; + +@BackendSpec(name = InMemoryBackendFactory.NAME) +public class TestInMemoryPersistence extends AbstractPersistenceTests { + @PolarisPersistence protected Persistence persistence; + + @Override + protected Persistence persistence() { + return persistence; + } +} diff --git a/persistence/nosql/persistence/db/inmemory/src/test/resources/logback-test.xml b/persistence/nosql/persistence/db/inmemory/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..fb74fc2c54 --- /dev/null +++ b/persistence/nosql/persistence/db/inmemory/src/test/resources/logback-test.xml @@ -0,0 +1,30 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/persistence/nosql/persistence/db/inmemory/src/testFixtures/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackendTestFactory.java b/persistence/nosql/persistence/db/inmemory/src/testFixtures/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackendTestFactory.java new file mode 100644 index 0000000000..9f2365d4a9 --- /dev/null +++ b/persistence/nosql/persistence/db/inmemory/src/testFixtures/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackendTestFactory.java @@ -0,0 +1,48 @@ +/* + * 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.inmemory; + +import java.util.Optional; +import org.apache.polaris.persistence.nosql.api.backend.Backend; +import org.apache.polaris.persistence.nosql.testextension.BackendTestFactory; + +public class InMemoryBackendTestFactory implements BackendTestFactory { + public static final String NAME = InMemoryBackendFactory.NAME; + + @Override + public Backend createNewBackend() { + return new InMemoryBackendFactory().buildBackend(new InMemoryBackendConfig()); + } + + @Override + public void start() {} + + @Override + public void start(Optional containerNetworkId) throws Exception { + BackendTestFactory.super.start(containerNetworkId); + } + + @Override + public void stop() {} + + @Override + public String name() { + return NAME; + } +} diff --git a/persistence/nosql/persistence/db/inmemory/src/testFixtures/resources/META-INF/services/org.apache.polaris.persistence.nosql.testextension.BackendTestFactory b/persistence/nosql/persistence/db/inmemory/src/testFixtures/resources/META-INF/services/org.apache.polaris.persistence.nosql.testextension.BackendTestFactory new file mode 100644 index 0000000000..43961b8762 --- /dev/null +++ b/persistence/nosql/persistence/db/inmemory/src/testFixtures/resources/META-INF/services/org.apache.polaris.persistence.nosql.testextension.BackendTestFactory @@ -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.inmemory.InMemoryBackendTestFactory diff --git a/persistence/nosql/persistence/impl/build.gradle.kts b/persistence/nosql/persistence/impl/build.gradle.kts new file mode 100644 index 0000000000..677ef6989c --- /dev/null +++ b/persistence/nosql/persistence/impl/build.gradle.kts @@ -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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + alias(libs.plugins.jmh) + id("polaris-server") +} + +description = "Polaris NoSQL persistence core implementation" + +dependencies { + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-persistence-nosql-varint")) + implementation(project(":polaris-idgen-api")) + implementation(project(":polaris-idgen-spi")) + + 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") + + implementation(libs.agrona) + implementation(libs.guava) + implementation(libs.slf4j.api) + implementation("io.micrometer:micrometer-core") + implementation(libs.caffeine) + + 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.config.core) + compileOnly(platform(libs.quarkus.bom)) + compileOnly("io.quarkus:quarkus-core") + implementation(platform(libs.micrometer.bom)) + + testFixturesApi(project(":polaris-persistence-nosql-api")) + testFixturesApi(testFixtures(project(":polaris-persistence-nosql-api"))) + testFixturesApi(project(":polaris-persistence-nosql-testextension")) + + testFixturesCompileOnly(platform(libs.jackson.bom)) + testFixturesCompileOnly("com.fasterxml.jackson.core:jackson-annotations") + testFixturesCompileOnly("com.fasterxml.jackson.core:jackson-core") + testFixturesCompileOnly("com.fasterxml.jackson.core:jackson-databind") + + testFixturesCompileOnly(libs.jakarta.annotation.api) + testFixturesCompileOnly(libs.jakarta.validation.api) + + testFixturesCompileOnly(project(":polaris-immutables")) + testFixturesAnnotationProcessor(project(":polaris-immutables", configuration = "processor")) + + testFixturesImplementation(libs.guava) + + testFixturesImplementation(libs.junit.pioneer) + + testImplementation(libs.threeten.extra) + testImplementation(testFixtures(project(":polaris-persistence-nosql-inmemory"))) + testImplementation(libs.junit.pioneer) + + testImplementation(project(":polaris-idgen-impl")) + + testCompileOnly(libs.jakarta.annotation.api) + testCompileOnly(libs.jakarta.validation.api) + + testCompileOnly(project(":polaris-immutables")) + testAnnotationProcessor(project(":polaris-immutables", configuration = "processor")) + + jmhImplementation(libs.jmh.core) + jmhAnnotationProcessor(libs.jmh.generator.annprocess) +} diff --git a/persistence/nosql/persistence/impl/src/jmh/java/org/apache/polaris/persistence/nosql/impl/indexes/RandomUuidKeyIndexImplBench.java b/persistence/nosql/persistence/impl/src/jmh/java/org/apache/polaris/persistence/nosql/impl/indexes/RandomUuidKeyIndexImplBench.java new file mode 100644 index 0000000000..b625d338ca --- /dev/null +++ b/persistence/nosql/persistence/impl/src/jmh/java/org/apache/polaris/persistence/nosql/impl/indexes/RandomUuidKeyIndexImplBench.java @@ -0,0 +1,90 @@ +/* + * 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.impl.indexes; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; + +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.impl.indexes.KeyIndexTestSet.IndexTestSetGenerator; +import org.apache.polaris.persistence.nosql.impl.indexes.KeyIndexTestSet.RandomUuidKeySet; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** Benchmark that uses {@link RandomUuidKeySet} to generate keys. */ +@Warmup(iterations = 3, time = 2000, timeUnit = MILLISECONDS) +@Measurement(iterations = 5, time = 1000, timeUnit = MILLISECONDS) +@Fork(1) +@Threads(4) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(MICROSECONDS) +public class RandomUuidKeyIndexImplBench { + @State(Scope.Benchmark) + public static class BenchmarkParam { + + @Param({"1000", "10000", "100000", "200000"}) + public int keys; + + private KeyIndexTestSet keyIndexTestSet; + + @Setup + public void init() { + IndexTestSetGenerator builder = + KeyIndexTestSet.newGenerator() + .keySet(ImmutableRandomUuidKeySet.builder().numKeys(keys).build()) + .elementSupplier(key -> indexElement(key, Util.randomObjId())) + .elementSerializer(OBJ_REF_SERIALIZER) + .build(); + + this.keyIndexTestSet = builder.generateIndexTestSet(); + + System.err.printf( + "%nNumber of tables: %d%nSerialized size: %d%n", + keyIndexTestSet.keys().size(), keyIndexTestSet.serializedSafe().remaining()); + } + } + + @Benchmark + public void serialize(BenchmarkParam param, Blackhole bh) { + bh.consume(param.keyIndexTestSet.serialize()); + } + + @Benchmark + public void deserialize(BenchmarkParam param, Blackhole bh) { + bh.consume(param.keyIndexTestSet.deserialize()); + } + + @Benchmark + public void randomGetKey(BenchmarkParam param, Blackhole bh) { + bh.consume(param.keyIndexTestSet.randomGetKey()); + } +} diff --git a/persistence/nosql/persistence/impl/src/jmh/java/org/apache/polaris/persistence/nosql/impl/indexes/RealisticKeyIndexImplBench.java b/persistence/nosql/persistence/impl/src/jmh/java/org/apache/polaris/persistence/nosql/impl/indexes/RealisticKeyIndexImplBench.java new file mode 100644 index 0000000000..82fe43f03a --- /dev/null +++ b/persistence/nosql/persistence/impl/src/jmh/java/org/apache/polaris/persistence/nosql/impl/indexes/RealisticKeyIndexImplBench.java @@ -0,0 +1,144 @@ +/* + * 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.impl.indexes; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.key; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; + +import java.util.Iterator; +import java.util.Map; +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.impl.indexes.KeyIndexTestSet.RealisticKeySet; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** Benchmark that uses {@link RealisticKeySet} to generate keys. */ +@Warmup(iterations = 3, time = 2000, timeUnit = MILLISECONDS) +@Measurement(iterations = 5, time = 1000, timeUnit = MILLISECONDS) +@Fork(1) +@Threads(4) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(MICROSECONDS) +public class RealisticKeyIndexImplBench { + @State(Scope.Benchmark) + public static class BenchmarkParam { + + @Param({"1", "3"}) + public int namespaceLevels; + + @Param({"5", "50"}) + public int foldersPerLevel; + + @Param({"25", "50", "100"}) + public int tablesPerNamespace; + + @Param({"true"}) + public boolean deterministic; + + private KeyIndexTestSet keyIndexTestSet; + + @Setup + public void init() { + KeyIndexTestSet.IndexTestSetGenerator builder = + KeyIndexTestSet.newGenerator() + .keySet( + ImmutableRealisticKeySet.builder() + .namespaceLevels(namespaceLevels) + .foldersPerLevel(foldersPerLevel) + .tablesPerNamespace(tablesPerNamespace) + .deterministic(deterministic) + .build()) + .elementSupplier(key -> indexElement(key, Util.randomObjId())) + .elementSerializer(OBJ_REF_SERIALIZER) + .build(); + + this.keyIndexTestSet = builder.generateIndexTestSet(); + + System.err.printf( + "%nNumber of tables: %d%nSerialized size: %d%n", + keyIndexTestSet.keys().size(), keyIndexTestSet.serializedSafe().remaining()); + } + } + + @Benchmark + public Object serializeUnmodifiedIndex(BenchmarkParam param) { + return param.keyIndexTestSet.serialize(); + } + + @Benchmark + public Object serializeModifiedIndex(BenchmarkParam param) { + IndexSpi deserialized = param.keyIndexTestSet.deserialize(); + ((IndexImpl) deserialized).setModified(); + return deserialized.serialize(); + } + + @Benchmark + public Object deserializeAdd(BenchmarkParam param) { + IndexSpi deserialized = param.keyIndexTestSet.deserialize(); + for (char c = 'a'; c <= 'z'; c++) { + deserialized.add(indexElement(key(c + "xkey"), Util.randomObjId())); + } + return deserialized; + } + + @Benchmark + public Object deserializeAddSerialize(BenchmarkParam param) { + IndexSpi deserialized = param.keyIndexTestSet.deserialize(); + for (char c = 'a'; c <= 'z'; c++) { + deserialized.add(indexElement(key(c + "xkey"), Util.randomObjId())); + } + return deserialized.serialize(); + } + + @Benchmark + public Object deserialize(BenchmarkParam param) { + return param.keyIndexTestSet.deserialize(); + } + + @Benchmark + public Object deserializeGetRandomKey(BenchmarkParam param) { + IndexSpi deserialized = param.keyIndexTestSet.deserialize(); + return deserialized.getElement(param.keyIndexTestSet.randomKey()); + } + + @Benchmark + public void deserializeIterate250(BenchmarkParam param, Blackhole bh) { + Index deserialized = param.keyIndexTestSet.deserialize(); + Iterator> iter = deserialized.iterator(); + for (int i = 0; i < 250 && iter.hasNext(); i++) { + bh.consume(iter.next()); + } + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/Identifiers.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/Identifiers.java new file mode 100644 index 0000000000..8de625ef59 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/Identifiers.java @@ -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. + */ +package org.apache.polaris.persistence.nosql.impl; + +/** Common identifiers used in the various database-specific implementations. */ +public final class Identifiers { + private Identifiers() {} + + // Use short column/field names that are as short as possible. + + public static final String COL_REALM = "r"; + + public static final String TABLE_REFS = "refs"; + + public static final String COL_REF_NAME = "n"; + public static final String COL_REF_POINTER = "p"; + public static final String COL_REF_CREATED_AT = "c"; + public static final String COL_REF_PREVIOUS = "t"; + + public static final String TABLE_OBJS = "objs"; + + public static final String COL_OBJ_TYPE = "t"; + public static final String COL_OBJ_ID = "i"; + public static final String COL_OBJ_PART = "p"; + public static final String COL_OBJ_REAL_PART_NUM = "q"; + public static final String COL_OBJ_VALUE = "d"; + public static final String COL_OBJ_VERSION = "v"; + public static final String COL_OBJ_CREATED_AT = "c"; +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/MultiByteArrayInputStream.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/MultiByteArrayInputStream.java new file mode 100644 index 0000000000..2efdfc0eb3 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/MultiByteArrayInputStream.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.impl; + +import static java.util.Objects.requireNonNull; + +import jakarta.annotation.Nonnull; +import java.io.InputStream; +import java.util.Iterator; +import java.util.List; + +final class MultiByteArrayInputStream extends InputStream { + private final Iterator sources; + private byte[] current; + private int pos; + + public MultiByteArrayInputStream(List sources) { + this.sources = sources.iterator(); + } + + @Override + public int read(@Nonnull byte[] b, int off, int len) { + while (true) { + if (checkCurrentEof()) { + return -1; + } + + if (pos >= current.length) { + current = null; + continue; + } + + var remain = current.length - pos; + var amount = Math.min(len, remain); + System.arraycopy(current, pos, b, off, amount); + pos += amount; + return amount; + } + } + + @Override + public int read() { + while (true) { + if (checkCurrentEof()) { + return -1; + } + + if (pos >= current.length) { + current = null; + continue; + } + return current[pos++] & 0xFF; + } + } + + private boolean checkCurrentEof() { + if (current == null) { + if (!sources.hasNext()) { + return true; + } + current = requireNonNull(sources.next(), "No source byte[] element is null"); + pos = 0; + } + return false; + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/PersistenceImplementation.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/PersistenceImplementation.java new file mode 100644 index 0000000000..eff4a17ef2 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/PersistenceImplementation.java @@ -0,0 +1,608 @@ +/* + * 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.impl; + +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.backend.PersistId.persistId; +import static org.apache.polaris.persistence.nosql.api.backend.PersistId.persistIdPart0; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.apache.polaris.persistence.nosql.api.obj.ObjSerializationHelper.contextualReader; +import static org.apache.polaris.persistence.nosql.api.obj.ObjTypes.objTypeById; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.util.ByteBufferBackedInputStream; +import com.fasterxml.jackson.dataformat.smile.databind.SmileMapper; +import com.google.common.primitives.Ints; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Array; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.stream.IntStream; +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.PersistId; +import org.apache.polaris.persistence.nosql.api.backend.WriteObj; +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.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.ImmutableReference; +import org.apache.polaris.persistence.nosql.api.ref.Reference; +import org.apache.polaris.persistence.nosql.impl.commits.CommitFactory; +import org.apache.polaris.persistence.nosql.impl.indexes.IndexesProvider; + +/** + * Base implementation that every database-specific implementation is encouraged to extend. + * + *

This class centralizes {@link Obj} de-serialization and parameter validations. + */ +public final class PersistenceImplementation implements Persistence { + private static final ObjectMapper SMILE_MAPPER = + new SmileMapper() + .findAndRegisterModules() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private static final ObjectWriter OBJ_WRITER = + SMILE_MAPPER.writer().withView(Obj.StorageView.class); + + // This is the maximum allowed serialized value size for any single object. + // No serialized object should ever become this big. A few MB for a single `Obj` is acceptable in + // rare situations. + private static final int MAX_ALLOWED_SERIALIZED_VALUE_SIZE = 1024 * 1024 * 1024; + + private final Backend backend; + private final PersistenceParams params; + private final String realmId; + private final MonotonicClock monotonicClock; + private final IdGenerator idGenerator; + private final int maxSerializedValueSize; + + public PersistenceImplementation( + Backend backend, + PersistenceParams params, + String realmId, + MonotonicClock monotonicClock, + IdGenerator idGenerator) { + + this.backend = backend; + this.params = params; + this.realmId = realmId; + this.monotonicClock = monotonicClock; + this.idGenerator = idGenerator; + this.maxSerializedValueSize = Ints.checkedCast(params.maxSerializedValueSize().asLong()); + } + + @Override + public IdGenerator idGenerator() { + return idGenerator; + } + + @Override + public MonotonicClock monotonicClock() { + return monotonicClock; + } + + @Override + public String realmId() { + return realmId; + } + + @Override + public PersistenceParams params() { + return params; + } + + @Override + public int maxSerializedValueSize() { + return maxSerializedValueSize; + } + + @Override + public long generateId() { + return idGenerator().generateId(); + } + + @Override + public ObjRef generateObjId(ObjType type) { + return objRef(type, generateId()); + } + + @Override + public void createReferencesSilent(Set referenceNames) { + backend.createReferences( + realmId, referenceNames.stream().map(n -> newReference(n, Optional.empty())).toList()); + } + + private Reference newReference(String name, Optional pointer) { + return ImmutableReference.builder() + .createdAtMicros(currentTimeMicros()) + .name(name) + .pointer(pointer) + .previousPointers() + .build(); + } + + @Nonnull + @Override + public Reference createReference(@Nonnull String name, @Nonnull Optional pointer) { + var newRef = newReference(name, pointer); + if (!backend.createReference(realmId, newRef)) { + throw new ReferenceAlreadyExistsException(name); + } + return newRef; + } + + @Override + @Nonnull + public Optional updateReferencePointer( + @Nonnull Reference reference, @Nonnull ObjRef newPointer) { + var current = reference.pointer(); + checkArgument( + !newPointer.equals(current.orElse(null)), + "New pointer must not be equal to the expected pointer."); + checkArgument( + current.isEmpty() || current.get().type().equals(newPointer.type()), + "New pointer must use the same ObjType as the current pointer."); + + var sizeLimit = params.referencePreviousHeadCount(); + var newPrevious = new long[sizeLimit]; + var newPreviousIdx = 0; + if (current.isPresent()) { + newPrevious[newPreviousIdx++] = current.get().id(); + } + for (var previousPointer : reference.previousPointers()) { + newPrevious[newPreviousIdx++] = previousPointer; + if (newPreviousIdx == sizeLimit) { + break; + } + } + if (newPreviousIdx < sizeLimit) { + newPrevious = Arrays.copyOf(newPrevious, newPreviousIdx); + } + + var updatedRef = + ImmutableReference.builder() + .from(reference) + .pointer(newPointer) + .previousPointers(newPrevious) + .build(); + + return backend.updateReference(realmId, updatedRef, current) + ? Optional.of(updatedRef) + : Optional.empty(); + } + + @Nonnull + @Override + public Reference fetchReference(@Nonnull String name) { + return backend.fetchReference(realmId, name); + } + + @Nullable + @Override + public T getImmediate(@Nonnull ObjRef id, @Nonnull Class clazz) { + return fetch(id, clazz); + } + + @Nullable + @Override + public T fetch(@Nonnull ObjRef id, @Nonnull Class clazz) { + return fetchMany(clazz, id)[0]; + } + + @Nonnull + @Override + public T[] fetchMany(@Nonnull Class clazz, @Nonnull ObjRef... ids) { + var fetchIds = asPersistIds(ids); + var fetched = backend.fetch(realmId, fetchIds); + + @SuppressWarnings("unchecked") + var r = (T[]) Array.newInstance(clazz, ids.length); + + for (var i = 0; i < ids.length; i++) { + var id = ids[i]; + if (id == null) { + continue; + } + + var f = fetched.get(persistId(id.id(), 0)); + if (f == null) { + continue; + } + + var numParts = f.realNumParts(); + if (numParts > fetched.size()) { + // The value of ObjId.numParts() is inconsistent with the real number of parts. + // There are more parts that need to be fetched. + fetchIds.clear(); + for (var p = fetched.size(); p < numParts; p++) { + fetchIds.add(persistId(id.id(), p)); + } + fetched.putAll(backend.fetch(realmId, fetchIds)); + } + var fetchedObjTypeId = f.type(); + try (var in = + numParts == 1 + ? new ByteArrayInputStream(f.serialized()) + : new MultiByteArrayInputStream( + IntStream.range(0, numParts) + .mapToObj( + p -> { + var part = fetched.get(persistId(id.id(), p)); + checkState( + part != null, + "Part #%s of %s of object %s does not exist in the database", + p, + numParts, + id); + checkState( + fetchedObjTypeId.equals(part.type()), + "Object type mismatch, expected '%s', got '%s'", + fetchedObjTypeId, + part.type()); + return part.serialized(); + }) + .toList())) { + r[i] = + deserializeObj( + fetchedObjTypeId, + id.id(), + numParts, + in, + f.versionToken(), + f.createdAtMicros(), + clazz); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + return r; + } + + private static HashSet asPersistIds(ObjRef[] ids) { + var fetchIds = new HashSet(); + for (ObjRef id : ids) { + if (id == null) { + continue; + } + var numParts = id.numParts(); + if (numParts == 0) { + numParts = 1; + } + checkArgument(numParts > 0, "numParts of %s must be greater than 0", id); + for (var p = 0; p < numParts; p++) { + fetchIds.add(persistId(id.id(), p)); + } + } + return fetchIds; + } + + @Nonnull + @Override + public T write(@Nonnull T obj, @Nonnull Class clazz) { + checkArgument(obj.versionToken() == null, "'obj' must have a null 'versionToken'"); + + var createdAtMicros = currentTimeMicros(); + + var serializedValue = serializeObj(obj); + var serializedSize = serializedValue.length; + checkArgument( + serializedSize <= MAX_ALLOWED_SERIALIZED_VALUE_SIZE, + "Serialized size %s is way too big", + serializedSize); + var numParts = (serializedSize + maxSerializedValueSize - 1) / maxSerializedValueSize; + var writes = new ArrayList(numParts + 1); + writeAddWriteObjs(numParts, writes, obj, createdAtMicros, serializedValue, serializedSize); + + backend.write(realmId, writes); + + @SuppressWarnings("unchecked") + var r = (T) obj.withCreatedAtMicros(createdAtMicros).withNumParts(numParts); + + return r; + } + + @SuppressWarnings("unchecked") + @Nonnull + @Override + public T[] writeMany(@Nonnull Class clazz, @Nonnull T... objs) { + var numObjs = objs.length; + + @SuppressWarnings("unchecked") + var r = (T[]) Array.newInstance(clazz, numObjs); + + if (numObjs > 0) { + var writes = new ArrayList(numObjs); + + var createdAtMicros = currentTimeMicros(); + for (var i = 0; i < numObjs; i++) { + var obj = objs[i]; + if (obj != null) { + checkArgument(obj.versionToken() == null, "'obj' must have a null 'versionToken'"); + + var serializedValue = serializeObj(obj); + var serializedSize = serializedValue.length; + checkArgument( + serializedSize <= MAX_ALLOWED_SERIALIZED_VALUE_SIZE, + "Serialized size %s is way too big", + serializedSize); + var numParts = (serializedSize + maxSerializedValueSize - 1) / maxSerializedValueSize; + writeAddWriteObjs( + numParts, writes, obj, createdAtMicros, serializedValue, serializedSize); + + @SuppressWarnings("unchecked") + var u = (T) obj.withCreatedAtMicros(createdAtMicros).withNumParts(numParts); + r[i] = u; + } + } + + backend.write(realmId, writes); + } + + return r; + } + + private void writeAddWriteObjs( + int numParts, + ArrayList writes, + T obj, + long createdAtMicros, + byte[] serializedValue, + int serializedSize) { + if (numParts == 1) { + writes.add( + new WriteObj(obj.type().id(), obj.id(), 0, createdAtMicros, serializedValue, numParts)); + } else { + for (int p = 0; p < numParts; p++) { + var off = p * maxSerializedValueSize; + var remain = serializedSize - off; + var len = Math.min(remain, maxSerializedValueSize); + var part = new byte[len]; + System.arraycopy(serializedValue, off, part, 0, len); + writes.add(new WriteObj(obj.type().id(), obj.id(), p, createdAtMicros, part, numParts)); + } + } + } + + @Override + public void delete(@Nonnull ObjRef id) { + deleteMany(id); + } + + @Override + public void deleteMany(@Nonnull ObjRef... ids) { + var deleteIds = asPersistIds(ids); + if (!deleteIds.isEmpty()) { + backend.delete(realmId, deleteIds); + } + } + + @Nullable + @Override + public T conditionalInsert(@Nonnull T obj, @Nonnull Class clazz) { + var versionToken = obj.versionToken(); + checkArgument(versionToken != null, "'obj' must have a non-null 'versionToken'"); + checkArgument(obj.numParts() == 1, "'obj' must have 'numParts' == 1"); + + var objId = objRef(obj); + var serializedValue = serializeObj(obj); + var serializedSize = serializedValue.length; + checkArgument( + serializedSize <= maxSerializedValueSize(), + "Length of serialized value %s of object %s must not exceed maximum allowed size %s", + serializedSize, + maxSerializedValueSize(), + objId); + + var createdAtMicros = currentTimeMicros(); + + @SuppressWarnings("unchecked") + var r = (T) obj.withCreatedAtMicros(createdAtMicros).withNumParts(1); + + return backend.conditionalInsert( + realmId, + obj.type().id(), + persistIdPart0(obj), + createdAtMicros, + versionToken, + serializedValue) + ? r + : null; + } + + @Nullable + @Override + public T conditionalUpdate( + @Nonnull T expected, @Nonnull T update, @Nonnull Class clazz) { + checkArgument( + expected.type().equals(update.type()) && expected.id() == update.id(), + "Obj ids between 'expected' and 'update' do not match"); + var expectedToken = expected.versionToken(); + var updateToken = update.versionToken(); + checkArgument( + expectedToken != null && updateToken != null, + "Both 'expected' and 'update' must have a non-null 'versionToken'"); + checkArgument( + !expectedToken.equals(updateToken), + "'versionToken' of 'expected' and 'update' must not be equal"); + checkArgument(expected.numParts() == 1, "'expected' must have 'numParts' == 1"); + checkArgument( + update.numParts() == 0 || update.numParts() == 1, + "'update' must have 'numParts' == 0 or 1"); + + var serializedValue = serializeObj(update); + var serializedSize = serializedValue.length; + checkArgument( + serializedSize <= maxSerializedValueSize(), + "Length of serialized value %s of object %s must not exceed maximum allowed size %s", + serializedSize, + maxSerializedValueSize(), + update); + + var createdAtMicros = currentTimeMicros(); + + if (backend.conditionalUpdate( + realmId, + update.type().id(), + persistIdPart0(update), + createdAtMicros, + updateToken, + expectedToken, + serializedValue)) { + @SuppressWarnings("unchecked") + var r = (T) update.withCreatedAtMicros(createdAtMicros).withNumParts(1); + return r; + } + return null; + } + + @Override + public boolean conditionalDelete(@Nonnull T expected, Class clazz) { + var expectedToken = expected.versionToken(); + checkArgument(expectedToken != null, "'obj' must have a non-null 'versionToken'"); + checkArgument(expected.numParts() == 1, "'expected' must have 'numParts' == 1"); + return backend.conditionalDelete(realmId, persistIdPart0(expected), expectedToken); + } + + @Override + public Commits commits() { + return CommitFactory.newCommits(this); + } + + @Override + public Committer createCommitter( + @Nonnull String refName, + @Nonnull Class referencedObjType, + @Nonnull Class resultType) { + return CommitFactory.newCommitter(this, refName, referencedObjType, resultType); + } + + @Override + public Index buildReadIndex( + @Nullable IndexContainer indexContainer, + @Nonnull IndexValueSerializer indexValueSerializer) { + return IndexesProvider.buildReadIndex(indexContainer, this, indexValueSerializer); + } + + @Override + public UpdatableIndex buildWriteIndex( + @Nullable IndexContainer indexContainer, + @Nonnull IndexValueSerializer indexValueSerializer) { + return IndexesProvider.buildWriteIndex(indexContainer, this, indexValueSerializer); + } + + public static T deserialize(byte[] binary, @Nonnull Class clazz) { + if (binary == null) { + return null; + } + try { + return SMILE_MAPPER.readValue(binary, clazz); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** Deserialize a byte array into an object of the given type, consumes the {@link ByteBuffer}. */ + public static T deserialize(ByteBuffer binary, @Nonnull Class clazz) { + if (binary == null) { + return null; + } + try { + return SMILE_MAPPER.readValue(new ByteBufferBackedInputStream(binary), clazz); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static byte[] serialize(Object o) { + try { + return SMILE_MAPPER.writeValueAsBytes(o); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public static byte[] serializeObj(Obj o) { + try { + // OBJ_WRITES uses the Jackson view mechanism to exclude the + // type, id, createdAtMicros, versionToken attributes from being + // serialized by Jackson here. + return OBJ_WRITER.writeValueAsBytes(o); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public static T deserializeObj( + String type, + long id, + int partNum, + InputStream in, + String versionToken, + long createdAtMicros, + @Nonnull Class clazz) + throws IOException { + var objType = objTypeById(type); + var typeClass = objType.targetClass(); + checkArgument( + clazz.isAssignableFrom(typeClass), + "Mismatch between persisted object type '%s' (%s) and deserialized %s. " + + "The object ID is possibly already used by another object. " + + "If the deserialized type is a GenericObj, ensure that the artifact providing the corresponding ObjType implementation is present and is present in META-INF/services/%s", + type, + typeClass, + clazz, + ObjType.class.getName()); + + var obj = + contextualReader(SMILE_MAPPER, objType, id, partNum, versionToken, createdAtMicros) + .readValue(in, typeClass); + @SuppressWarnings("unchecked") + var r = (T) obj; + return r; + } + + @Override + public String toString() { + return format("Persistence for realm '%s'", realmId()); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/CachingPersistenceImpl.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/CachingPersistenceImpl.java new file mode 100644 index 0000000000..fb2a346611 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/CachingPersistenceImpl.java @@ -0,0 +1,388 @@ +/* + * 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.impl.cache; + +import static com.google.common.base.Preconditions.checkArgument; +import static org.apache.polaris.persistence.nosql.api.cache.CacheBackend.NON_EXISTENT_REFERENCE_SENTINEL; +import static org.apache.polaris.persistence.nosql.api.cache.CacheBackend.NOT_FOUND_OBJ_SENTINEL; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.lang.reflect.Array; +import java.util.Optional; +import java.util.Set; +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.cache.CacheBackend; +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.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.impl.commits.CommitFactory; +import org.apache.polaris.persistence.nosql.impl.indexes.IndexesProvider; + +class CachingPersistenceImpl implements Persistence { + + private final String realmId; + final Persistence delegate; + final CacheBackend backend; + + CachingPersistenceImpl(Persistence delegate, CacheBackend backend) { + this.delegate = delegate; + this.backend = backend; + this.realmId = delegate.realmId(); + } + + @Nullable + @Override + public T getImmediate(@Nonnull ObjRef id, @Nonnull Class clazz) { + var numParts = id.numParts(); + checkArgument(numParts >= 0, "partNum of %s must not be negative", id); + @SuppressWarnings("unchecked") + var o = (T) backend.get(realmId, id); + if (o != null && o != NOT_FOUND_OBJ_SENTINEL) { + return o; + } + return null; + } + + @Nullable + @Override + public T fetch(@Nonnull ObjRef id, @Nonnull Class clazz) { + var numParts = id.numParts(); + checkArgument(numParts >= 0, "partNum of %s must not be negative", id); + var o = backend.get(realmId, id); + if (o != null) { + if (o != NOT_FOUND_OBJ_SENTINEL) { + return checkCast(o, clazz); + } + return null; + } + + var f = delegate.fetch(id, clazz); + if (f == null) { + backend.putNegative(realmId, id); + } else { + backend.putLocal(realmId, f); + } + return f; + } + + @Nonnull + @Override + public T[] fetchMany(@Nonnull Class clazz, @Nonnull ObjRef... ids) { + @SuppressWarnings("unchecked") + var r = (T[]) Array.newInstance(clazz, ids.length); + + var backendIds = fetchObjsPre(ids, r, clazz); + + if (backendIds == null) { + return r; + } + + var backendResult = delegate.fetchMany(clazz, backendIds); + return fetchObjsPost(backendIds, backendResult, r); + } + + private ObjRef[] fetchObjsPre(ObjRef[] ids, T[] r, Class clazz) { + ObjRef[] backendIds = null; + for (var i = 0; i < ids.length; i++) { + var id = ids[i]; + if (id == null) { + continue; + } + var numParts = id.numParts(); + checkArgument(numParts >= 0, "partNum of %s must not be negative", id); + var o = backend.get(realmId, id); + if (o != null) { + if (o != NOT_FOUND_OBJ_SENTINEL) { + r[i] = checkCast(o, clazz); + } + } else { + if (backendIds == null) { + backendIds = new ObjRef[ids.length]; + } + backendIds[i] = id; + } + } + return backendIds; + } + + private T checkCast(Obj o, Class clazz) { + var type = o.type(); + var typeClass = type.targetClass(); + checkArgument( + clazz.isAssignableFrom(typeClass), + "Mismatch between persisted object type '%s' (%s) and deserialized %s. " + + "The object ID %s is possibly already used by another object. " + + "If the deserialized type is a GenericObj, ensure that the artifact providing the corresponding ObjType implementation is present and is present in META-INF/services/%s", + type.id(), + typeClass, + clazz, + o.id(), + ObjType.class.getName()); + return clazz.cast(o); + } + + private T[] fetchObjsPost(ObjRef[] backendIds, T[] backendResult, T[] r) { + for (var i = 0; i < backendResult.length; i++) { + var id = backendIds[i]; + if (id != null) { + var o = backendResult[i]; + if (o != null) { + r[i] = o; + backend.putLocal(realmId, o); + } else { + backend.putNegative(realmId, id); + } + } + } + return r; + } + + @Nonnull + @Override + public T write(@Nonnull T obj, @Nonnull Class clazz) { + obj = delegate.write(obj, clazz); + backend.put(realmId, obj); + return obj; + } + + @SafeVarargs + @Nonnull + @Override + public final T[] writeMany(@Nonnull Class clazz, @Nonnull T... objs) { + var written = delegate.writeMany(clazz, objs); + for (var w : written) { + if (w != null) { + backend.put(realmId, w); + } + } + return written; + } + + @Override + public void delete(@Nonnull ObjRef id) { + try { + delegate.delete(id); + } finally { + backend.remove(realmId, id); + } + } + + @Override + public void deleteMany(@Nonnull ObjRef... ids) { + try { + delegate.deleteMany(ids); + } finally { + for (var id : ids) { + if (id != null) { + backend.remove(realmId, id); + } + } + } + } + + @Nullable + @Override + public T conditionalInsert(@Nonnull T obj, @Nonnull Class clazz) { + var r = delegate.conditionalInsert(obj, clazz); + if (r != null) { + backend.put(realmId, obj); + } else { + backend.remove(realmId, objRef(obj)); + } + return r; + } + + @Nullable + @Override + public T conditionalUpdate( + @Nonnull T expected, @Nonnull T update, @Nonnull Class clazz) { + var r = delegate.conditionalUpdate(expected, update, clazz); + if (r != null) { + backend.put(realmId, r); + } else { + backend.remove(realmId, objRef(expected)); + } + return r; + } + + @Override + public boolean conditionalDelete(@Nonnull T expected, Class clazz) { + try { + return delegate.conditionalDelete(expected, clazz); + } finally { + backend.remove(realmId, objRef(expected)); + } + } + + // plain delegates... + + @Override + public PersistenceParams params() { + return delegate.params(); + } + + @Override + public int maxSerializedValueSize() { + return delegate.maxSerializedValueSize(); + } + + @Override + public long generateId() { + return delegate.generateId(); + } + + @Override + public ObjRef generateObjId(ObjType type) { + return delegate.generateObjId(type); + } + + @Override + public Commits commits() { + return CommitFactory.newCommits(this); + } + + @Override + public Committer createCommitter( + @Nonnull String refName, + @Nonnull Class referencedObjType, + @Nonnull Class resultType) { + return CommitFactory.newCommitter(this, refName, referencedObjType, resultType); + } + + @Override + public Index buildReadIndex( + @Nullable IndexContainer indexContainer, + @Nonnull IndexValueSerializer indexValueSerializer) { + return IndexesProvider.buildReadIndex(indexContainer, this, indexValueSerializer); + } + + @Override + public UpdatableIndex buildWriteIndex( + @Nullable IndexContainer indexContainer, + @Nonnull IndexValueSerializer indexValueSerializer) { + return IndexesProvider.buildWriteIndex(indexContainer, this, indexValueSerializer); + } + + // References + + @Nonnull + @Override + public Reference createReference(@Nonnull String name, @Nonnull Optional pointer) { + Reference r = null; + try { + return r = delegate.createReference(name, pointer); + } finally { + if (r != null) { + backend.putReference(realmId, r); + } else { + backend.removeReference(realmId, name); + } + } + } + + @Override + public void createReferencesSilent(Set referenceNames) { + delegate.createReferencesSilent(referenceNames); + referenceNames.forEach(n -> backend.removeReference(realmId, n)); + } + + @Override + @Nonnull + public Optional updateReferencePointer( + @Nonnull Reference reference, @Nonnull ObjRef newPointer) { + Optional r = Optional.empty(); + try { + r = delegate.updateReferencePointer(reference, newPointer); + } finally { + if (r.isPresent()) { + backend.putReference(realmId, r.get()); + } else { + backend.removeReference(realmId, reference.name()); + } + } + return r; + } + + @Override + @Nonnull + public Reference fetchReference(@Nonnull String name) { + return fetchReferenceInternal(name, false); + } + + @Override + @Nonnull + public Reference fetchReferenceForUpdate(@Nonnull String name) { + return fetchReferenceInternal(name, true); + } + + private Reference fetchReferenceInternal(@Nonnull String name, boolean bypassCache) { + Reference r = null; + if (!bypassCache) { + r = backend.getReference(realmId, name); + if (r == NON_EXISTENT_REFERENCE_SENTINEL) { + throw new ReferenceNotFoundException(name); + } + } + + if (r == null) { + try { + r = delegate.fetchReferenceForUpdate(name); + backend.putReferenceLocal(realmId, r); + } catch (ReferenceNotFoundException e) { + backend.putReferenceNegative(realmId, name); + throw e; + } + } + return r; + } + + @Override + public String realmId() { + return delegate.realmId(); + } + + @Override + public MonotonicClock monotonicClock() { + return delegate.monotonicClock(); + } + + @Override + public IdGenerator idGenerator() { + return delegate.idGenerator(); + } + + @Override + public String toString() { + return delegate.toString() + ", caching"; + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/CaffeineCacheBackend.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/CaffeineCacheBackend.java new file mode 100644 index 0000000000..b38959b610 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/CaffeineCacheBackend.java @@ -0,0 +1,722 @@ +/* + * 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.impl.cache; + +import static com.google.common.base.Preconditions.checkState; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.MICROSECONDS; +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static org.apache.polaris.persistence.nosql.api.cache.CacheConfig.DEFAULT_REFERENCE_TTL; +import static org.apache.polaris.persistence.nosql.api.cache.CacheSizing.DEFAULT_CACHE_CAPACITY_OVERSHOOT; +import static org.apache.polaris.persistence.nosql.api.cache.CacheSizing.DEFAULT_HEAP_FRACTION; +import static org.apache.polaris.persistence.nosql.api.obj.ObjSerializationHelper.contextualReader; +import static org.apache.polaris.persistence.nosql.api.obj.ObjType.CACHE_UNLIMITED; +import static org.apache.polaris.persistence.nosql.api.obj.ObjType.NOT_CACHED; +import static org.apache.polaris.persistence.nosql.api.obj.ObjTypes.objTypeById; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.CacheKeyValue.KIND_FLAG_CREATED; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.CacheKeyValue.KIND_FLAG_NUM_PARTS; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.CacheKeyValue.KIND_FLAG_OBJ_ID; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.CacheKeyValue.KIND_FLAG_VERSION; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.CacheKeyValue.KIND_REFERENCE; +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 com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.dataformat.smile.databind.SmileMapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.google.common.annotations.VisibleForTesting; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.BaseUnits; +import io.micrometer.core.instrument.binder.cache.CaffeineStatsCounter; +import jakarta.annotation.Nonnull; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.IntConsumer; +import java.util.function.LongSupplier; +import org.apache.polaris.misc.types.memorysize.MemorySize; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.cache.CacheBackend; +import org.apache.polaris.persistence.nosql.api.cache.CacheConfig; +import org.apache.polaris.persistence.nosql.api.cache.ImmutableCacheSizing; +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.ref.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class CaffeineCacheBackend implements CacheBackend { + + private static final Logger LOGGER = LoggerFactory.getLogger(CaffeineCacheBackend.class); + + public static final String METER_CACHE_CAPACITY = "cache.capacity"; + public static final String METER_CACHE_ADMIT_CAPACITY = "cache.capacity.admitted"; + public static final String METER_CACHE_WEIGHT = "cache.weight-reported"; + public static final String METER_CACHE_REJECTED_WEIGHT = "cache.rejected-weight"; + + public static final String CACHE_NAME = "polaris-objects"; + private static final CacheKeyValue NON_EXISTING_SENTINEL = + new CacheKeyValue("", cacheKeyObjId("CACHE_SENTINEL", 0L, 1, 0L, null), 0L, new byte[0]); + + private final CacheConfig config; + final Cache cache; + + private final long refCacheTtlNanos; + private final long refCacheNegativeTtlNanos; + private final long capacityBytes; + private final long admitWeight; + private final AtomicLong rejections = new AtomicLong(); + private final IntConsumer rejectionsWeight; + private final LongSupplier weightSupplier; + + private final Lock aboveCapacityLock; + + CaffeineCacheBackend(CacheConfig config, Optional meterRegistry) { + // Runnable::run as the executor means that eviction runs on a caller thread and is not delayed. + this(config, meterRegistry, Runnable::run); + } + + CaffeineCacheBackend( + CacheConfig config, Optional meterRegistry, Executor executor) { + this.config = config; + + refCacheTtlNanos = config.referenceTtl().orElse(DEFAULT_REFERENCE_TTL).toNanos(); + refCacheNegativeTtlNanos = config.referenceNegativeTtl().orElse(Duration.ZERO).toNanos(); + + var sizing = config.sizing().orElse(ImmutableCacheSizing.builder().build()); + capacityBytes = + sizing.calculateEffectiveSize(Runtime.getRuntime().maxMemory(), DEFAULT_HEAP_FRACTION); + + admitWeight = + capacityBytes + + (long) + (capacityBytes + * sizing.cacheCapacityOvershoot().orElse(DEFAULT_CACHE_CAPACITY_OVERSHOOT)); + + var cacheBuilder = + Caffeine.newBuilder() + .executor(executor) + .scheduler(Scheduler.systemScheduler()) + .ticker(config.clockNanos()::getAsLong) + .maximumWeight(capacityBytes) + .weigher(this::weigher) + .expireAfter( + new Expiry() { + @Override + public long expireAfterCreate( + @Nonnull CacheKeyValue key, + @Nonnull CacheKeyValue value, + long currentTimeNanos) { + var expire = key.expiresAtNanosEpoch; + if (expire == CACHE_UNLIMITED) { + return Long.MAX_VALUE; + } + if (expire == NOT_CACHED) { + return 0L; + } + var remaining = expire - currentTimeNanos; + return Math.max(0L, remaining); + } + + @Override + public long expireAfterUpdate( + @Nonnull CacheKeyValue key, + @Nonnull CacheKeyValue value, + long currentTimeNanos, + long currentDurationNanos) { + return expireAfterCreate(key, value, currentTimeNanos); + } + + @Override + public long expireAfterRead( + @Nonnull CacheKeyValue key, + @Nonnull CacheKeyValue value, + long currentTimeNanos, + long currentDurationNanos) { + return currentDurationNanos; + } + }); + rejectionsWeight = + meterRegistry + .map( + reg -> { + cacheBuilder.recordStats(() -> new CaffeineStatsCounter(reg, CACHE_NAME)); + Gauge.builder(METER_CACHE_CAPACITY, "", x -> capacityBytes) + .description("Total capacity of the objects cache in bytes.") + .tag("cache", CACHE_NAME) + .baseUnit(BaseUnits.BYTES) + .register(reg); + Gauge.builder(METER_CACHE_ADMIT_CAPACITY, "", x -> admitWeight) + .description("Admitted capacity of the objects cache in bytes.") + .tag("cache", CACHE_NAME) + .baseUnit(BaseUnits.BYTES) + .register(reg); + Gauge.builder(METER_CACHE_WEIGHT, "", x -> (double) currentWeightReported()) + .description("Current reported weight of the objects cache in bytes.") + .tag("cache", CACHE_NAME) + .baseUnit(BaseUnits.BYTES) + .register(reg); + var rejectedWeightSummary = + DistributionSummary.builder(METER_CACHE_REJECTED_WEIGHT) + .description("Weight of of rejected cache-puts in bytes.") + .tag("cache", CACHE_NAME) + .baseUnit(BaseUnits.BYTES) + .register(reg); + return (IntConsumer) rejectedWeightSummary::record; + }) + .orElse(x -> {}); + + LOGGER.info( + "Initialized persistence cache with a capacity of ~ {} MB", + (MemorySize.ofBytes(capacityBytes).asLong() / 1024L / 1024L)); + + this.cache = cacheBuilder.build(); + + var eviction = cache.policy().eviction().orElseThrow(); + weightSupplier = () -> eviction.weightedSize().orElse(0L); + + aboveCapacityLock = new ReentrantLock(); + } + + @VisibleForTesting + long currentWeightReported() { + return weightSupplier.getAsLong(); + } + + @VisibleForTesting + long rejections() { + return rejections.get(); + } + + @VisibleForTesting + long capacityBytes() { + return capacityBytes; + } + + @VisibleForTesting + long admitWeight() { + return admitWeight; + } + + @Override + public Persistence wrap(@Nonnull Persistence persist) { + return new CachingPersistenceImpl(persist, this); + } + + private int weigher(CacheKeyValue key, CacheKeyValue value) { + var size = key.heapSize(); + size += CAFFEINE_OBJ_OVERHEAD; + return size; + } + + @Override + public Obj get(@Nonnull String realmId, @Nonnull ObjRef id) { + var key = cacheKeyValueObjRead(realmId, id); + var value = cache.getIfPresent(key); + if (value == null) { + return null; + } + if (value == NON_EXISTING_SENTINEL) { + return NOT_FOUND_OBJ_SENTINEL; + } + return value.getObj(); + } + + @Override + public void put(@Nonnull String realmId, @Nonnull Obj obj) { + putLocal(realmId, obj); + } + + @VisibleForTesting + void cachePut(CacheKeyValue key, CacheKeyValue value) { + var w = weigher(key, value); + var currentWeight = weightSupplier.getAsLong(); + if (currentWeight < capacityBytes) { + cache.put(key, value); + return; + } + + aboveCapacityLock.lock(); + try { + cache.cleanUp(); + currentWeight = weightSupplier.getAsLong(); + if (currentWeight + w < admitWeight) { + cache.put(key, value); + } else { + rejections.incrementAndGet(); + rejectionsWeight.accept(w); + } + } finally { + aboveCapacityLock.unlock(); + } + } + + @Override + public void putLocal(@Nonnull String realmId, @Nonnull Obj obj) { + long expiresAt = + obj.type() + .cachedObjectExpiresAtMicros( + obj, () -> NANOSECONDS.toMicros(config.clockNanos().getAsLong())); + if (expiresAt == NOT_CACHED) { + return; + } + + var expiresAtNanos = + expiresAt == CACHE_UNLIMITED ? CACHE_UNLIMITED : MICROSECONDS.toNanos(expiresAt); + var keyValue = cacheKeyValueObj(realmId, obj, expiresAtNanos); + cachePut(keyValue, keyValue); + } + + @Override + public void putNegative(@Nonnull String realmId, @Nonnull ObjRef id) { + var type = objTypeById(id.type()); + var expiresAt = + type.negativeCacheExpiresAtMicros( + () -> NANOSECONDS.toMicros(config.clockNanos().getAsLong())); + if (expiresAt == NOT_CACHED) { + remove(realmId, id); + return; + } + + var expiresAtNanos = + expiresAt == CACHE_UNLIMITED ? CACHE_UNLIMITED : MICROSECONDS.toNanos(expiresAt); + var keyValue = cacheKeyValueNegative(realmId, cacheKeyObjId(id), expiresAtNanos); + + cachePut(keyValue, NON_EXISTING_SENTINEL); + } + + @Override + public void remove(@Nonnull String realmId, @Nonnull ObjRef id) { + cache.invalidate(cacheKeyValueObjRead(realmId, id)); + } + + @Override + public void clear(@Nonnull String realmId) { + cache.asMap().keySet().removeIf(k -> k.realmId.equals(realmId)); + } + + @Override + public void purge() { + cache.asMap().clear(); + } + + @Override + public long estimatedSize() { + return cache.estimatedSize(); + } + + @Override + public void removeReference(@Nonnull String realmId, @Nonnull String name) { + if (refCacheTtlNanos <= 0L) { + return; + } + cache.invalidate(cacheKeyValueReferenceRead(realmId, name)); + } + + @Override + public void putReference(@Nonnull String realmId, @Nonnull Reference reference) { + putReferenceLocal(realmId, reference); + } + + @Override + public void putReferenceLocal(@Nonnull String realmId, @Nonnull Reference reference) { + if (refCacheTtlNanos <= 0L) { + return; + } + var expiresAtNanos = config.clockNanos().getAsLong() + refCacheTtlNanos; + var keyValue = cacheKeyValueReference(realmId, reference, expiresAtNanos); + cachePut(keyValue, keyValue); + } + + @Override + public void putReferenceNegative(@Nonnull String realmId, @Nonnull String name) { + if (refCacheNegativeTtlNanos <= 0L) { + return; + } + var key = + cacheKeyValueNegative( + realmId, + cacheKeyReference(name), + config.clockNanos().getAsLong() + refCacheNegativeTtlNanos); + cachePut(key, NON_EXISTING_SENTINEL); + } + + @Override + public Reference getReference(@Nonnull String realmId, @Nonnull String name) { + if (refCacheTtlNanos <= 0L) { + return null; + } + var value = cache.getIfPresent(cacheKeyValueReferenceRead(realmId, name)); + if (value == null) { + return null; + } + if (value == NON_EXISTING_SENTINEL) { + return NON_EXISTENT_REFERENCE_SENTINEL; + } + return value.getReference(); + } + + @VisibleForTesting + static CacheKeyValue cacheKeyValueObj( + @Nonnull String realmId, @Nonnull Obj obj, long expiresAtNanos) { + var serialized = serializeObj(obj); + return new CacheKeyValue(realmId, cacheKeyObj(obj), expiresAtNanos, serialized); + } + + @VisibleForTesting + static CacheKeyValue cacheKeyValueObjRead(@Nonnull String realmId, @Nonnull ObjRef id) { + return new CacheKeyValue(realmId, cacheKeyObjId(id), 0L, null); + } + + @VisibleForTesting + static CacheKeyValue cacheKeyValueReference( + String realmId, Reference reference, long expiresAtNanos) { + return new CacheKeyValue( + realmId, + cacheKeyReference(reference.name()), + expiresAtNanos, + serializeReference(reference)); + } + + @VisibleForTesting + static CacheKeyValue cacheKeyValueReferenceRead(@Nonnull String realmId, @Nonnull String name) { + return new CacheKeyValue(realmId, cacheKeyReference(name), 0L, null); + } + + @VisibleForTesting + static CacheKeyValue cacheKeyValueNegative( + @Nonnull String realmId, @Nonnull byte[] key, long expiresAtNanosEpoch) { + return new CacheKeyValue(realmId, key, expiresAtNanosEpoch, null); + } + + @VisibleForTesting + static byte[] cacheKeyObj(@Nonnull Obj obj) { + return cacheKeyObjId( + obj.type().id(), obj.id(), obj.numParts(), obj.createdAtMicros(), obj.versionToken()); + } + + @VisibleForTesting + static byte[] cacheKeyObjId(@Nonnull ObjRef id) { + return cacheKeyObjId(id.type(), id.id(), id.numParts(), 0L, null); + } + + private static byte[] cacheKeyObjId( + String type, long id, int numParts, long createdAtMicros, String versionToken) { + var typeBytes = type.getBytes(UTF_8); + var relevantLen = Long.BYTES + typeBytes.length; + var relevantSerializedLen = varIntLen(relevantLen); + var versionTokenBytes = versionToken != null ? versionToken.getBytes(UTF_8) : null; + + var keyLen = 1 + relevantSerializedLen + relevantLen; + var kind = KIND_FLAG_OBJ_ID; + if (numParts != 1) { + keyLen += varIntLen(numParts); + kind |= KIND_FLAG_NUM_PARTS; + } + if (createdAtMicros != 0L) { + keyLen += Long.BYTES; + kind |= KIND_FLAG_CREATED; + } + if (versionTokenBytes != null) { + keyLen += versionTokenBytes.length; + kind |= KIND_FLAG_VERSION; + } + + var key = new byte[keyLen]; + var buf = ByteBuffer.wrap(key); + + buf.put(kind); + putVarInt(buf, relevantLen); + buf.putLong(id); + buf.put(typeBytes); + if (numParts != 1) { + putVarInt(buf, numParts); + } + if (createdAtMicros != 0L) { + buf.putLong(createdAtMicros); + } + if (versionTokenBytes != null) { + buf.put(versionTokenBytes); + } + return key; + } + + @VisibleForTesting + static byte[] cacheKeyReference(String refName) { + var refNameBytes = refName.getBytes(UTF_8); + var key = new byte[1 + refNameBytes.length]; + var buf = ByteBuffer.wrap(key); + buf.put(KIND_REFERENCE); + buf.put(refNameBytes); + return key; + } + + /** + * Class used for both the cache key and cache value including the expiration timestamp. This is + * (should be) more efficient (think: monomorphic vs. bi-morphic call sizes) and more GC/heap + * friendly (less object-instances) than having different object types. + */ + static final class CacheKeyValue { + + static final byte KIND_REFERENCE = 0; + static final byte KIND_FLAG_OBJ_ID = 1; + static final byte KIND_FLAG_NUM_PARTS = 2; + static final byte KIND_FLAG_CREATED = 4; + static final byte KIND_FLAG_VERSION = 8; + + final String realmId; + final byte[] key; + final int hash; + + // Revisit this field before 2262-04-11T23:47:16.854Z (64-bit signed long overflow) ;) ;) + final long expiresAtNanosEpoch; + + final byte[] serialized; + + CacheKeyValue(String realmId, byte[] key, long expiresAtNanosEpoch, byte[] serialized) { + this.realmId = realmId; + this.key = key; + this.expiresAtNanosEpoch = expiresAtNanosEpoch; + this.serialized = serialized; + + var hash = realmId.hashCode(); + var buf = ByteBuffer.wrap(key); + var kind = buf.get(); + if (kind == KIND_REFERENCE) { + hash = hash * 31 + Arrays.hashCode(key); + } else { + var relevantLen = readVarInt(buf); + hash = hash * 31 + buf.limit(buf.position() + relevantLen).hashCode(); + } + + this.hash = hash; + } + + /** + * Provide a good estimate about the heap usage of this object. The goal of this + * implementation is to rather yield a potentially higher value than a too low value. + * + *

The implementation neglects the soft-referenced object, as that can be relatively easily + * collected by the Java GC. + */ + int heapSize() { + var size = CACHE_KEY_VALUE_SIZE; + // realm id (String) + size += STRING_SIZE + ARRAY_OVERHEAD + realmId.length(); + // serialized obj-key + size += ARRAY_OVERHEAD + key.length; + // serialized value + byte[] s = serialized; + if (s != null) { + size += ARRAY_OVERHEAD + s.length; + } + return size; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CacheKeyValue that)) { + return false; + } + if (!this.realmId.equals(that.realmId)) { + return false; + } + + var thisBuf = ByteBuffer.wrap(this.key); + var thatBuf = ByteBuffer.wrap(that.key); + var thisKind = thisBuf.get(); + var thatKind = thatBuf.get(); + + if (thisKind == KIND_REFERENCE && thatKind == KIND_REFERENCE) { + return Arrays.equals(this.key, that.key); + } + if (thisKind == KIND_REFERENCE || thatKind == KIND_REFERENCE) { + return false; + } + + // must be an object ID + var thisRelevantLen = readVarInt(thisBuf); + var thatRelevantLen = readVarInt(thatBuf); + if (thisRelevantLen != thatRelevantLen) { + return false; + } + var off = thisBuf.position(); + var to = off + thisRelevantLen; + return Arrays.equals(this.key, off, to, that.key, off, to); + } + + @Override + public int hashCode() { + return hash; + } + + @Override + public String toString() { + var sb = new StringBuilder("{"); + sb.append(realmId).append(", "); + + var buf = ByteBuffer.wrap(this.key); + var kind = buf.get(); + + if (kind == KIND_REFERENCE) { + var referenceName = new String(key, 1, key.length - 1, UTF_8); + sb.append("reference:").append(referenceName); + } else { + var relevantLen = readVarInt(buf); + var id = buf.getLong(); + var typeIdLen = relevantLen - Long.BYTES; + var typeId = new String(key, buf.position(), typeIdLen, UTF_8); + sb.append("obj:").append(typeId).append("/").append(id); + buf.position(buf.position() + typeIdLen); + if ((kind & KIND_FLAG_NUM_PARTS) == KIND_FLAG_NUM_PARTS) { + sb.append(", numParts:").append(readVarInt(buf)); + } + if ((kind & KIND_FLAG_CREATED) == KIND_FLAG_CREATED) { + sb.append(", createdAtMicros:").append(buf.getLong()); + } + if ((kind & KIND_FLAG_VERSION) == KIND_FLAG_VERSION) { + sb.append(", versionToken:") + .append(new String(key, buf.position(), key.length - buf.position(), UTF_8)); + } + } + + return sb.append("}").toString(); + } + + Obj getObj() { + var buf = ByteBuffer.wrap(this.key); + var kind = buf.get(); + checkState(kind != KIND_REFERENCE, "Cache value content is not an object"); + var relevantLen = readVarInt(buf); + var id = buf.getLong(); + var typeIdLen = relevantLen - Long.BYTES; + var typeId = new String(key, buf.position(), typeIdLen, UTF_8); + var type = objTypeById(typeId); + buf.position(buf.position() + typeIdLen); + var numParts = ((kind & KIND_FLAG_NUM_PARTS) == KIND_FLAG_NUM_PARTS) ? readVarInt(buf) : 1; + var createdAtMicros = ((kind & KIND_FLAG_CREATED) == KIND_FLAG_CREATED) ? buf.getLong() : 0L; + var versionToken = + ((kind & KIND_FLAG_VERSION) == KIND_FLAG_VERSION) + ? new String(key, buf.position(), key.length - buf.position(), UTF_8) + : null; + + try { + return contextualReader(SMILE_MAPPER, type, id, numParts, versionToken, createdAtMicros) + .readValue(serialized); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + Reference getReference() { + var kind = key[0]; + checkState(kind == KIND_REFERENCE, "Cache value content is not a reference"); + try { + return SMILE_MAPPER.readValue(serialized, Reference.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** + * "Worst" CacheKeyValue heap layout size as reported by {@code jol internals-estimates}. Note: + * "Lilliput" would bring it down to 32 bytes. + * + *

Worst layout:

+   * ***** Hotspot Layout Simulation (JDK 15, 64-bit model, NO compressed references, NO compressed classes, 8-byte aligned)
+   *
+   * org.apache.polaris.persistence.cache.CaffeineCacheBackend$CacheKeyValue object internals:
+   * OFF  SZ               TYPE DESCRIPTION                         VALUE
+   *   0   8                    (object header: mark)               N/A
+   *   8   8                    (object header: class)              N/A
+   *  16   8               long CacheKeyValue.expiresAtNanosEpoch   N/A
+   *  24   4                int CacheKeyValue.hash                  N/A
+   *  28   4                    (alignment/padding gap)
+   *  32   8   java.lang.String CacheKeyValue.realmId               N/A
+   *  40   8             byte[] CacheKeyValue.key                   N/A
+   *  48   8             byte[] CacheKeyValue.serialized            N/A
+   * Instance size: 56 bytes
+   * Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
+   * 
+ */ + static final int CACHE_KEY_VALUE_SIZE = 64; + + /** + * Worst layout of {@code java.lang.String}:
+   * ***** Hotspot Layout Simulation (JDK 15, 64-bit model, NO compressed references, NO compressed classes, 8-byte aligned)
+   *
+   * java.lang.String object internals:
+   * OFF  SZ      TYPE DESCRIPTION               VALUE
+   *   0   8           (object header: mark)     N/A
+   *   8   8           (object header: class)    N/A
+   *  16   4       int String.hash               N/A
+   *  20   1      byte String.coder              N/A
+   *  21   1   boolean String.hashIsZero         N/A
+   *  22   2           (alignment/padding gap)
+   *  24   8    byte[] String.value              N/A
+   * Instance size: 32 bytes
+   * Space losses: 2 bytes internal + 0 bytes external = 2 bytes total
+   * 
+ */ + static final int STRING_SIZE = 32; + + static final int ARRAY_OVERHEAD = 16; + static final int CAFFEINE_OBJ_OVERHEAD = 2 * 32; + + static final ObjectMapper SMILE_MAPPER = + new SmileMapper() + .findAndRegisterModules() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private static final ObjectWriter OBJ_WRITER = SMILE_MAPPER.writer().withView(Object.class); + + static byte[] serializeObj(Obj obj) { + try { + return OBJ_WRITER.writeValueAsBytes(obj); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + static byte[] serializeReference(Reference ref) { + try { + return SMILE_MAPPER.writeValueAsBytes(ref); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/DistributedInvalidationsCacheBackend.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/DistributedInvalidationsCacheBackend.java new file mode 100644 index 0000000000..0503f2f00f --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/DistributedInvalidationsCacheBackend.java @@ -0,0 +1,117 @@ +/* + * 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.impl.cache; + +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; + +import jakarta.annotation.Nonnull; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.cache.CacheBackend; +import org.apache.polaris.persistence.nosql.api.cache.DistributedCacheInvalidation; +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.ref.Reference; + +final class DistributedInvalidationsCacheBackend implements CacheBackend { + private final CacheBackend local; + private final DistributedCacheInvalidation.Sender sender; + + DistributedInvalidationsCacheBackend( + CacheBackend localBackend, DistributedCacheInvalidation.Sender invalidationSender) { + this.local = localBackend; + this.sender = invalidationSender; + } + + @Override + public Persistence wrap(@Nonnull Persistence persist) { + return new CachingPersistenceImpl(persist, this); + } + + @Override + public Obj get(@Nonnull String realmId, @Nonnull ObjRef id) { + return local.get(realmId, id); + } + + @Override + public void put(@Nonnull String realmId, @Nonnull Obj obj) { + // Note: .put() vs .putLocal() doesn't matter here, because 'local' is the local cache. + local.putLocal(realmId, obj); + sender.evictObj(realmId, objRef(obj)); + } + + @Override + public void putLocal(@Nonnull String realmId, @Nonnull Obj obj) { + local.putLocal(realmId, obj); + } + + @Override + public void putNegative(@Nonnull String realmId, @Nonnull ObjRef id) { + local.putNegative(realmId, id); + } + + @Override + public void remove(@Nonnull String realmId, @Nonnull ObjRef id) { + local.remove(realmId, id); + sender.evictObj(realmId, id); + } + + @Override + public void clear(@Nonnull String realmId) { + local.clear(realmId); + } + + @Override + public void purge() { + local.purge(); + } + + @Override + public long estimatedSize() { + return local.estimatedSize(); + } + + @Override + public Reference getReference(@Nonnull String realmId, @Nonnull String name) { + return local.getReference(realmId, name); + } + + @Override + public void removeReference(@Nonnull String realmId, @Nonnull String name) { + local.removeReference(realmId, name); + sender.evictReference(realmId, name); + } + + @Override + public void putReferenceLocal(@Nonnull String realmId, @Nonnull Reference reference) { + local.putReferenceLocal(realmId, reference); + } + + @Override + public void putReference(@Nonnull String realmId, @Nonnull Reference reference) { + // Note: .putReference() vs .putReferenceLocal() doesn't matter here, because 'local' is the + // local cache. + local.putReferenceLocal(realmId, reference); + sender.evictReference(realmId, reference.name()); + } + + @Override + public void putReferenceNegative(@Nonnull String realmId, @Nonnull String name) { + local.putReferenceNegative(realmId, name); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/NoopCacheBackend.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/NoopCacheBackend.java new file mode 100644 index 0000000000..05f18e72e6 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/NoopCacheBackend.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.impl.cache; + +import jakarta.annotation.Nonnull; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.cache.CacheBackend; +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.ref.Reference; + +final class NoopCacheBackend implements CacheBackend { + + static final NoopCacheBackend INSTANCE = new NoopCacheBackend(); + + private NoopCacheBackend() {} + + @Override + public Persistence wrap(@Nonnull Persistence persistence) { + return persistence; + } + + @Override + public void putReferenceNegative(@Nonnull String repositoryId, @Nonnull String name) {} + + @Override + public void putReference(@Nonnull String repositoryId, @Nonnull Reference r) {} + + @Override + public void putReferenceLocal(@Nonnull String repositoryId, @Nonnull Reference r) {} + + @Override + public void removeReference(@Nonnull String repositoryId, @Nonnull String name) {} + + @Override + public Reference getReference(@Nonnull String repositoryId, @Nonnull String name) { + return null; + } + + @Override + public void clear(@Nonnull String repositoryId) {} + + @Override + public void purge() {} + + @Override + public long estimatedSize() { + return 0; + } + + @Override + public void remove(@Nonnull String repositoryId, @Nonnull ObjRef id) {} + + @Override + public void putNegative(@Nonnull String repositoryId, @Nonnull ObjRef id) {} + + @Override + public void put(@Nonnull String repositoryId, @Nonnull Obj obj) {} + + @Override + public void putLocal(@Nonnull String repositoryId, @Nonnull Obj obj) {} + + @Override + public Obj get(@Nonnull String repositoryId, @Nonnull ObjRef id) { + return null; + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/PersistenceCacheDecorator.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/PersistenceCacheDecorator.java new file mode 100644 index 0000000000..09291faf32 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/PersistenceCacheDecorator.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.impl.cache; + +import static org.apache.polaris.persistence.nosql.api.cache.CacheConfig.DEFAULT_ENABLE; + +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Any; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.PersistenceDecorator; +import org.apache.polaris.persistence.nosql.api.cache.CacheBackend; +import org.apache.polaris.persistence.nosql.api.cache.CacheConfig; +import org.apache.polaris.persistence.nosql.api.cache.DistributedCacheInvalidation; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; + +/** + * Decorator adding the application global cache to a {@link Persistence}, exposes priority {@value + * #PRIORITY}. + */ +@ApplicationScoped +class PersistenceCacheDecorator implements PersistenceDecorator { + static int PRIORITY = 1000; + + private final CacheConfig cacheConfig; + + private final CacheBackend local; + private final CacheBackend cacheBackend; + + @Inject + PersistenceCacheDecorator( + CacheConfig cacheConfig, + @Any Instance meterRegistry, + @Any Instance invalidationSender) { + this.cacheConfig = cacheConfig; + + if (!cacheConfig.enable().orElse(DEFAULT_ENABLE)) { + local = cacheBackend = NoopCacheBackend.INSTANCE; + } else { + local = PersistenceCaches.newBackend(cacheConfig, meterRegistry.stream().findAny()); + cacheBackend = + invalidationSender.isResolvable() + ? new DistributedInvalidationsCacheBackend(local, invalidationSender.get()) + : local; + } + } + + @Produces + CacheBackend cacheBackend() { + return cacheBackend; + } + + @Produces + DistributedCacheInvalidation.Receiver distributedCacheInvalidationHandler() { + return new DistributedCacheInvalidation.Receiver() { + @Override + public void evictObj(@Nonnull String realmId, @Nonnull ObjRef objRef) { + local.remove(realmId, objRef); + } + + @Override + public void evictReference(@Nonnull String realmId, @Nonnull String refName) { + local.removeReference(realmId, refName); + } + }; + } + + @Override + public boolean active() { + return cacheConfig.enable().orElse(DEFAULT_ENABLE); + } + + @Override + public int priority() { + return PRIORITY; + } + + @Override + public Persistence decorate(Persistence persistence) { + return cacheBackend.wrap(persistence); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/PersistenceCaches.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/PersistenceCaches.java new file mode 100644 index 0000000000..8a222360c6 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/PersistenceCaches.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.impl.cache; + +import io.micrometer.core.instrument.MeterRegistry; +import java.util.Optional; +import org.apache.polaris.persistence.nosql.api.cache.CacheBackend; +import org.apache.polaris.persistence.nosql.api.cache.CacheConfig; + +public final class PersistenceCaches { + private PersistenceCaches() {} + + /** Produces a {@link CacheBackend} with the given maximum capacity. */ + public static CacheBackend newBackend( + CacheConfig cacheConfig, Optional meterRegistry) { + return new CaffeineCacheBackend(cacheConfig, meterRegistry); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitFactory.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitFactory.java new file mode 100644 index 0000000000..09f31b5c18 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitFactory.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.impl.commits; + +import jakarta.annotation.Nonnull; +import org.apache.polaris.persistence.nosql.api.Persistence; +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.obj.BaseCommitObj; +import org.apache.polaris.persistence.nosql.api.ref.Reference; + +public final class CommitFactory { + private CommitFactory() {} + + /** + * Create a new {@link Commits} instance for the given {@link Persistence} instance. + * + *

Note: In a CDI container {@link Commits} can be directly injected, a call to this function + * is not required. + */ + public static Commits newCommits(Persistence persistence) { + return new CommitsImpl(persistence); + } + + /** + * Creates a new {@link Committer} instance. + * + * @param persistence persistence used + * @param refName name of the reference + * @param referencedObjType type of the {@linkplain Reference#pointer() referenced} object + * @return new committer + * @param type of the {@linkplain Reference#pointer() referenced} object + * @param the commit result type, for successful commits including non-changing + */ + public static Committer newCommitter( + @Nonnull Persistence persistence, + @Nonnull String refName, + @Nonnull Class referencedObjType, + @Nonnull Class resultType) { + return new CommitterImpl<>(persistence, refName, referencedObjType, resultType); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitSynchronizer.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitSynchronizer.java new file mode 100644 index 0000000000..257a93504e --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitSynchronizer.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.impl.commits; + +interface CommitSynchronizer { + void before(long nanosRemaining); + + void after(); + + CommitSynchronizer NON_SYNCHRONIZING = + new CommitSynchronizer() { + @Override + public void before(long nanosRemaining) {} + + @Override + public void after() {} + }; +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitsImpl.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitsImpl.java new file mode 100644 index 0000000000..017f1dc7a8 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitsImpl.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.impl.commits; + +import static java.util.Collections.emptyIterator; +import static java.util.Collections.singletonList; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; + +import com.google.common.collect.AbstractIterator; +import jakarta.inject.Inject; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.OptionalLong; +import org.agrona.collections.LongArrayList; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.commit.Commits; +import org.apache.polaris.persistence.nosql.api.obj.BaseCommitObj; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; + +final class CommitsImpl implements Commits { + private final Persistence persistence; + private static final int REVERSE_COMMIT_FETCH_SIZE = 20; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + CommitsImpl(Persistence persistence) { + this.persistence = persistence; + } + + @Override + public Iterator commitLogReversed( + String refName, long offset, Class clazz) { + var headOpt = persistence.fetchReferenceHead(refName, clazz); + if (headOpt.isEmpty()) { + return emptyIterator(); + } + + var head = headOpt.get(); + var type = head.type().id(); + + // find commit with Obj.id() == offset, memoize visited commits + + // Contains the seen IDs, without the 'offset', in _natural_ order (most recent commit ID first) + var visited = new LongArrayList(); + + // TODO add safeguard to limit the work done when finding the commit with ID 'offset' + + // Only walk, if the most recent commit ID is != offset + if (head.id() == offset) { + return emptyIterator(); + } + + visited.add(head.id()); + var tail = head.tail(); + outer: + while (tail.length != 0) { + for (var tailId : tail) { + if (tailId == offset) { + break outer; + } + visited.add(tailId); + } + + while (!visited.isEmpty()) { + var idx = visited.size() - 1; + var cid = objRef(type, visited.getLong(idx), 1); + var commit = persistence.fetch(cid, clazz); + if (commit != null) { + tail = commit.tail(); + break; + } + + // If the commit with the last ID in 'visited' was not found, ignore it and try the next + // recent commit. This is a legit case when the commit log gets truncated. + visited.removeAt(idx); + } + } + + // return iterator + + return new AbstractIterator<>() { + private int index = visited.size(); + + private Iterator pageIter = emptyIterator(); + + @Override + protected C computeNext() { + while (true) { + if (pageIter.hasNext()) { + var r = pageIter.next(); + if (r == null) { + // stop at commits that do not exist, the history has been cut at that point. + return endOfData(); + } + return r; + } + + if (index == 0) { + return endOfData(); + } + + var ids = new ArrayList(REVERSE_COMMIT_FETCH_SIZE); + for (var i = 0; i < REVERSE_COMMIT_FETCH_SIZE; i++) { + ids.add(objRef(type, visited.getLong(--index), 1)); + if (index == 0) { + break; + } + } + + if (ids.isEmpty()) { + return endOfData(); + } + + var commits = persistence.fetchMany(clazz, ids.toArray(new ObjRef[0])); + pageIter = Arrays.asList(commits).iterator(); + } + } + }; + } + + @Override + public Iterator commitLog( + String refName, OptionalLong offset, Class clazz) { + var headOpt = persistence.fetchReferenceHead(refName, clazz); + if (headOpt.isEmpty()) { + return emptyIterator(); + } + + var head = headOpt.get(); + var type = head.type().id(); + + // TODO add safeguard to limit the work done when finding the commit with ID 'offset' + + if (offset.isPresent()) { + var off = offset.getAsLong(); + + // Only walk, if the most recent commit ID is != offset + if (head.id() == off) { + return singletonList(head).iterator(); + } + + var tail = head.tail(); + outer: + while (tail.length != 0) { + var lastId = 0L; + for (var tailId : tail) { + if (tailId == off) { + head = null; // force fetch + break outer; + } + lastId = tailId; + } + + var id = objRef(type, lastId, 1); + head = persistence.fetch(id, clazz); + + if (head == null || head.id() == off) { + break; + } + + tail = head.tail(); + } + + if (head == null) { + var id = objRef(type, off, 1); + head = persistence.fetch(id, clazz); + } + } + + if (head == null) { + return emptyIterator(); + } + + var headIter = List.of(head).iterator(); + return new AbstractIterator<>() { + private C lastCommit; + + private Iterator pageIter = headIter; + + @Override + protected C computeNext() { + while (true) { + if (pageIter.hasNext()) { + var c = pageIter.next(); + if (c == null) { + // stop at commits that do not exist, the history has been cut at that point. + return endOfData(); + } + lastCommit = c; + return c; + } + + var tail = lastCommit.tail(); + if (tail.length == 0) { + return endOfData(); + } + lastCommit = null; + + var ids = new ObjRef[REVERSE_COMMIT_FETCH_SIZE]; + for (var i = 0; i < REVERSE_COMMIT_FETCH_SIZE && i < tail.length; i++) { + ids[i] = objRef(type, tail[i], 1); + } + + var page = persistence.fetchMany(clazz, ids); + pageIter = Arrays.asList(page).iterator(); + } + } + }; + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitterImpl.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitterImpl.java new file mode 100644 index 0000000000..a48947929e --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitterImpl.java @@ -0,0 +1,514 @@ +/* + * 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.impl.commits; + +import static com.google.common.base.Preconditions.checkState; +import static java.util.Objects.requireNonNull; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.lang.reflect.Array; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Supplier; +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.commit.RetryTimeoutException; +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.ref.Reference; +import org.apache.polaris.persistence.nosql.impl.commits.retry.RetryLoop; +import org.apache.polaris.persistence.nosql.impl.commits.retry.RetryStatsConsumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class CommitterImpl + implements CommitterWithStats { + private static final Logger LOGGER = LoggerFactory.getLogger(CommitterImpl.class); + + /** + * For testing purposes, add a random sleep within the given bound in milliseconds before each + * commit attempt's reference bump attempt. This value can be useful when debugging concurrency + * issues. + */ + private static final int RANDOM_SLEEP_BOUND = + Integer.getInteger("x-polaris.persistence.committer.random.sleep-bound", 0); + + private final Persistence persistence; + private final String refName; + private final Class referenceType; + private final Class resultType; + private boolean synchronizingLocally; + + private static final Object NO_RESULT_SENTINEL = new Object() {}; + + @SuppressWarnings("unchecked") + private RESULT noResultSentinel() { + return (RESULT) NO_RESULT_SENTINEL; + } + + CommitterImpl( + Persistence persistence, + String refName, + Class referenceType, + Class resultType) { + this.persistence = persistence; + this.refName = refName; + this.referenceType = referenceType; + this.resultType = resultType; + } + + @Override + public CommitterWithStats synchronizingLocally() { + this.synchronizingLocally = true; + return this; + } + + @Override + public Optional commit(CommitRetryable commitRetryable) + throws CommitException, RetryTimeoutException { + return commit(commitRetryable, null); + } + + @Override + public Optional commit( + CommitRetryable commitRetryable, RetryStatsConsumer retryStatsConsumer) + throws CommitException, RetryTimeoutException { + var committerState = new CommitterStateImpl(persistence); + LOGGER.debug("commit start"); + + var sync = + synchronizingLocally + ? ExclusiveCommitSynchronizer.forKey(persistence.realmId(), refName) + : CommitSynchronizer.NON_SYNCHRONIZING; + + try { + var retryConfig = persistence.params().retryConfig(); + var loop = RetryLoop.newRetryLoop(retryConfig, persistence.monotonicClock()); + if (retryStatsConsumer != null) { + loop.setRetryStatsConsumer(retryStatsConsumer); + } + var result = + loop.retryLoop( + nanosRemaining -> { + try { + sync.before(nanosRemaining); + return commitAttempt(committerState, commitRetryable); + } finally { + sync.after(); + } + }); + if (result == noResultSentinel()) { + LOGGER.debug("commit() yielding no result"); + return Optional.ofNullable(committerState.result); + } + LOGGER.debug("commit() yielding result"); + return Optional.of(result); + } catch (RetryTimeoutException | RuntimeException e) { + LOGGER.debug("commit() failed"); + committerState.deleteIds.addAll(committerState.allPersistedIds); + throw e; + } finally { + committerState.deleteIds.removeAll(committerState.mustNotDelete); + if (!committerState.deleteIds.isEmpty()) { + LOGGER.debug("commit() deleting {}", committerState.deleteIds); + persistence.deleteMany(committerState.deleteIds.toArray(new ObjRef[0])); + } + } + } + + static final class CommitterStateImpl + implements CommitterState { + final Persistence persistence; + private Persistence delegate; + final Map forAttempt = new LinkedHashMap<>(); + final Set allPersistedIds = new HashSet<>(); + final Set idsUsed = new HashSet<>(); + final Set mustNotDelete = new HashSet<>(); + final Set deleteIds = new HashSet<>(); + final Map objs = new HashMap<>(); + boolean noCommit; + RESULT result; + + CommitterStateImpl(Persistence persistence) { + this.persistence = persistence; + } + + @Override + public Optional noCommit(@Nonnull RESULT result) { + noCommit = true; + this.result = result; + return Optional.empty(); + } + + @Override + public Optional noCommit() { + noCommit = true; + this.result = null; + return Optional.empty(); + } + + @Override + public Persistence persistence() { + var delegate = this.delegate; + if (delegate == null) { + delegate = + this.delegate = + new DelegatingPersistence(persistence) { + @Nullable + @Override + public T fetch(@Nonnull ObjRef id, @Nonnull Class clazz) { + T obj = getWrittenById(id, clazz); + if (obj == null) { + obj = persistence.fetch(id, clazz); + if (obj != null) { + mustNotDelete.add(id); + } + } + return obj; + } + + @Nonnull + @Override + public T[] fetchMany( + @Nonnull Class clazz, @Nonnull ObjRef... ids) { + @SuppressWarnings("unchecked") + var r = (T[]) Array.newInstance(clazz, ids.length); + var persistenceIds = Arrays.copyOf(ids, ids.length); + + var left = 0; + for (int i = 0; i < persistenceIds.length; i++) { + var id = persistenceIds[i]; + if (id != null) { + var obj = getWrittenById(id, clazz); + if (obj != null) { + r[i] = obj; + persistenceIds[i] = null; + } else { + left++; + } + } + } + + if (left > 0) { + var fromPersistence = persistence.fetchMany(clazz, persistenceIds); + for (int i = 0; i < fromPersistence.length; i++) { + var obj = fromPersistence[i]; + if (obj != null) { + r[i] = obj; + mustNotDelete.add(ids[i]); + } + } + } + + return r; + } + }; + } + return delegate; + } + + @Override + public > Optional commitResult( + @Nonnull RESULT result, @Nonnull B refObjBuilder, @Nonnull Optional refObj) { + long[] tail; + if (refObj.isPresent()) { + var r = refObj.get(); + refObjBuilder.seq(r.seq() + 1); + var t = r.tail(); + var max = persistence.params().referencePreviousHeadCount(); + if (t.length < max) { + tail = new long[t.length + 1]; + System.arraycopy(t, 0, tail, 1, t.length); + } else { + tail = new long[max]; + System.arraycopy(t, 0, tail, 1, max - 1); + } + tail[0] = r.id(); + } else { + tail = new long[0]; + refObjBuilder.seq(1L); + } + this.result = requireNonNull(result); + var id = persistence.generateId(); + return Optional.of(refObjBuilder.id(id).tail(tail).build()); + } + + @Override + public Obj getWrittenByKey(@Nonnull Object key) { + return objs.get(key); + } + + @Override + public C getWrittenById(ObjRef id, Class clazz) { + @SuppressWarnings("unchecked") + var r = (C) forAttempt.get(id); + return r; + } + + @Override + public O writeIfNew( + @Nonnull Object key, @Nonnull O obj, @Nonnull Class type) { + var objId = objRef(obj); + checkState( + !mustNotDelete.contains(objId), + "Object ID '%s' is forbidden, because it is used by a fetched object", + objId); + return type.cast( + objs.computeIfAbsent( + key, + k -> { + // Check state _before_ mutating it + checkState( + !idsUsed.contains(objId), + "Object ID '%s' to be persisted has already been used. " + + "This is a bug in the calling code.", + objId); + idsUsed.add(objId); + forAttempt.put(objId, obj); + return obj; + })); + } + + @Override + public void writeIntent(@Nonnull Object key, @Nonnull Obj obj) { + var objId = objRef(obj); + checkState( + !mustNotDelete.contains(objId), + "Object ID '%s' is forbidden, because it is used by a fetched object", + objId); + + // Check state _before_ mutating it + checkState( + !idsUsed.contains(objId), + "Object ID '%s' to be persisted has already been used. " + + "This is a bug in the calling code.", + objId); + checkState( + objs.putIfAbsent(key, obj) == null, "The object-key '%s' has already been used", key); + idsUsed.add(objId); + forAttempt.put(objId, obj); + } + + @Override + public O writeOrReplace( + @Nonnull Object key, @Nonnull O obj, @Nonnull Class type) { + var objId = objRef(obj); + LOGGER.debug("writeOrReplace '{}' {}", key, objId); + checkState( + !mustNotDelete.contains(objId), + "Object ID '%s' is forbidden, because it is used by a fetched object", + objId); + + return type.cast( + objs.compute( + key, + (k, ex) -> { + if (ex != null) { + var exId = objRef(ex); + // Fail if the ID of the new object is not equal to the ID of the existing object + // AND if the ID of the existing object is already scheduled for deletion. + var sameId = exId.equals(objId); + + if (sameId) { + checkState(idsUsed.contains(exId)); + + if (forAttempt.get(objId) == ex) { + LOGGER.debug("writeOrReplace - same, not yet persisted ID"); + forAttempt.put(objId, obj); + return obj; + } + + throw new IllegalStateException( + "Object with the same ID has already been persisted, cannot replace it, key = '" + + k + + "', objId = " + + objId); + } else { + for (var existing : forAttempt.entrySet()) { + var exObj = existing.getValue(); + if (exObj == ex) { + LOGGER.debug("writeOrReplace - same, not yet persisted object"); + // If there's an object _pending_ to be persisted from the _current_ + // attempt, remove it + checkState(exId.equals(objRef(ex))); + checkState(exId.equals(objRef(exObj))); + forAttempt.remove(existing.getKey()); + idsUsed.remove(exId); + break; + } + } + LOGGER.debug("writeOrReplace - replacing"); + deleteIds.add(exId); + idsUsed.add(exId); + forAttempt.put(objId, obj); + idsUsed.add(objId); + return obj; + } + } + + // New 'key' + LOGGER.debug("writeOrReplace - new key"); + checkState( + !idsUsed.contains(objId), + "Object ID '%s' to be persisted has already been used. " + + "This is a bug in the calling code.", + objId); + idsUsed.add(objId); + forAttempt.put(objId, obj); + return obj; + })); + } + } + + private void randomDelay() { + if (RANDOM_SLEEP_BOUND == 0) { + return; + } + + var i = ThreadLocalRandom.current().nextInt(RANDOM_SLEEP_BOUND); + if (i > 0) { + try { + Thread.sleep(i); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } + + Optional commitAttempt( + CommitterState stateApi, CommitRetryable commitRetryable) + throws CommitException { + LOGGER.debug("commitAttempt"); + + var state = (CommitterStateImpl) stateApi; + + var referenceHolder = new Reference[1]; + var refObjHolder = new Optional[1]; + + var refObjSupplier = + (Supplier>) + () -> { + var reference = persistence.fetchReferenceForUpdate(refName); + var refObj = reference.pointer().map(id -> persistence.fetch(id, referenceType)); + refObjHolder[0] = refObj; + referenceHolder[0] = reference; + LOGGER.debug( + "Referenced object {} for commit attempt for reference '{}'", refObj, refName); + return refObj; + }; + + var attemptResult = commitRetryable.attempt(state, refObjSupplier); + if (state.noCommit) { + LOGGER.debug("Commit-retryable instructs to not commit"); + return Optional.of(noResultSentinel()); + } + if (attemptResult.isEmpty()) { + LOGGER.debug("Commit-retryable yields no result"); + return Optional.empty(); + } + + @SuppressWarnings("unchecked") + var refObj = (Optional) refObjHolder[0]; + var reference = referenceHolder[0]; + checkState(reference != null, "CommitRetryable must call the provided refObj supplier"); + + var refObjId = refObj.map(ObjRef::objRef); + refObjId.ifPresent(state.mustNotDelete::add); + + var resultObj = attemptResult.get(); + checkState( + referenceType.isInstance(resultObj), + "Result object is not an instance of %s", + referenceType); + var resultObjRef = objRef(resultObj); + if (refObjId.isPresent() && refObjId.orElseThrow().equals(resultObjRef)) { + checkState( + state.forAttempt.isEmpty(), + "CommitRetryable.attempt() returned the current reference's pointer, in this case it must not attempt to persist any objects"); + checkState( + resultObj.equals(refObj.orElseThrow()), + "CommitRetryable.attempt() must not modify the returned object when using the same ID"); + + LOGGER.debug("Commit yields no change, not committing"); + + if (state.result != null) { + return Optional.of(state.result); + } + if (resultType.isAssignableFrom(referenceType)) { + return attemptResult.map(resultType::cast); + } + throw new IllegalStateException( + "CommitRetryable.attempt() did not set a result via CommitterState.commitResult and the result type " + + resultType.getName() + + " cannot be casted to the reference obj type " + + referenceType.getName()); + } + + state.forAttempt.put(resultObjRef, resultObj); + var objs = state.forAttempt.values().toArray(new Obj[0]); + state.forAttempt.clear(); + var persisted = persistence.writeMany(Obj.class, objs); + // exclude the resultObj's ID here, handled below + for (int i = 0; i < persisted.length - 1; i++) { + state.allPersistedIds.add(objRef(persisted[i])); + } + @SuppressWarnings("unchecked") + var persistedResultObj = (REF_OBJ) persisted[persisted.length - 1]; + + // For testing purposes only + randomDelay(); + + var newReference = persistence.updateReferencePointer(reference, resultObjRef); + if (newReference.isEmpty()) { + state.deleteIds.add(resultObjRef); + LOGGER.debug( + "Unsuccessful commit attempt (will retry, if possible) from {} to {}", + reference, + resultObjRef); + } else { + state.allPersistedIds.add(resultObjRef); + } + return newReference.map( + newRef -> { + LOGGER.debug("Successfully commited change from {} to {}", reference, newRef); + if (state.result != null) { + return state.result; + } + if (resultType.isAssignableFrom(referenceType)) { + return resultType.cast(persistedResultObj); + } + throw new IllegalStateException( + "CommitRetryable.attempt() did not set a non-null result via CommitterState.commitResult and the result type " + + resultType.getName() + + " cannot be casted to the reference obj type " + + referenceType.getName()); + }); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitterWithStats.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitterWithStats.java new file mode 100644 index 0000000000..7d02bd24a2 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitterWithStats.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.impl.commits; + +import java.util.Optional; +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.Committer; +import org.apache.polaris.persistence.nosql.api.commit.RetryTimeoutException; +import org.apache.polaris.persistence.nosql.api.obj.BaseCommitObj; +import org.apache.polaris.persistence.nosql.impl.commits.retry.RetryStatsConsumer; + +/** + * Extension of {@link Committer} that provides retry-information callbacks, used for testing + * purposes. + */ +public interface CommitterWithStats + extends Committer { + + Optional commit( + CommitRetryable commitRetryable, RetryStatsConsumer retryStatsConsumer) + throws CommitException, RetryTimeoutException; +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/DelegatingPersistence.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/DelegatingPersistence.java new file mode 100644 index 0000000000..c4f14ef2b4 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/DelegatingPersistence.java @@ -0,0 +1,243 @@ +/* + * 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.impl.commits; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +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; + +abstract class DelegatingPersistence implements Persistence { + protected final Persistence delegate; + + protected DelegatingPersistence(Persistence persistence) { + this.delegate = persistence; + } + + @Nonnull + @Override + public Reference createReference(@Nonnull String name, @Nonnull Optional pointer) + throws ReferenceAlreadyExistsException { + return delegate.createReference(name, pointer); + } + + @Override + public void createReferenceSilent(@Nonnull String name) { + delegate.createReferenceSilent(name); + } + + @Override + public void createReferencesSilent(Set referenceNames) { + delegate.createReferencesSilent(referenceNames); + } + + @Nonnull + @Override + public Reference fetchOrCreateReference( + @Nonnull String name, @Nonnull Supplier> pointerForCreate) { + return delegate.fetchOrCreateReference(name, pointerForCreate); + } + + @Nonnull + @Override + public Optional updateReferencePointer( + @Nonnull Reference reference, @Nonnull ObjRef newPointer) throws ReferenceNotFoundException { + return delegate.updateReferencePointer(reference, newPointer); + } + + @Nonnull + @Override + public Reference fetchReference(@Nonnull String name) throws ReferenceNotFoundException { + return delegate.fetchReference(name); + } + + @Nonnull + @Override + public Reference fetchReferenceForUpdate(@Nonnull String name) throws ReferenceNotFoundException { + return delegate.fetchReferenceForUpdate(name); + } + + @Override + public Optional fetchReferenceHead( + @Nonnull String name, @Nonnull Class clazz) throws ReferenceNotFoundException { + return delegate.fetchReferenceHead(name, clazz); + } + + @Nullable + @Override + public T fetch(@Nonnull ObjRef id, @Nonnull Class clazz) { + return delegate.fetch(id, clazz); + } + + @Nonnull + @Override + public T[] fetchMany(@Nonnull Class clazz, @Nonnull ObjRef... ids) { + return delegate.fetchMany(clazz, ids); + } + + @Nonnull + @Override + public T write(@Nonnull T obj, @Nonnull Class clazz) { + return delegate.write(obj, clazz); + } + + @SuppressWarnings("unchecked") + @Nonnull + @Override + public T[] writeMany(@Nonnull Class clazz, @Nonnull T... objs) { + return delegate.writeMany(clazz, objs); + } + + @Override + public void delete(@Nonnull ObjRef id) { + delegate.delete(id); + } + + @Override + public void deleteMany(@Nonnull ObjRef... ids) { + delegate.deleteMany(ids); + } + + @Nullable + @Override + public T conditionalInsert(@Nonnull T obj, @Nonnull Class clazz) { + return delegate.conditionalInsert(obj, clazz); + } + + @Nullable + @Override + public T conditionalUpdate( + @Nonnull T expected, @Nonnull T update, @Nonnull Class clazz) { + return delegate.conditionalUpdate(expected, update, clazz); + } + + @Override + public boolean conditionalDelete(@Nonnull T expected, Class clazz) { + return delegate.conditionalDelete(expected, clazz); + } + + @Override + public PersistenceParams params() { + return delegate.params(); + } + + @Override + public int maxSerializedValueSize() { + return delegate.maxSerializedValueSize(); + } + + @Override + public long generateId() { + return delegate.generateId(); + } + + @Override + public ObjRef generateObjId(ObjType type) { + return delegate.generateObjId(type); + } + + @Nullable + @Override + public T getImmediate(@Nonnull ObjRef id, @Nonnull Class clazz) { + return delegate.getImmediate(id, clazz); + } + + @Override + public Commits commits() { + return delegate.commits(); + } + + @Override + public Committer createCommitter( + @Nonnull String refName, + @Nonnull Class referencedObjType, + @Nonnull Class resultType) { + return delegate.createCommitter(refName, referencedObjType, resultType); + } + + @Override + public Index buildReadIndex( + @Nullable IndexContainer indexContainer, + @Nonnull IndexValueSerializer indexValueSerializer) { + return delegate.buildReadIndex(indexContainer, indexValueSerializer); + } + + @Override + public UpdatableIndex buildWriteIndex( + @Nullable IndexContainer indexContainer, + @Nonnull IndexValueSerializer indexValueSerializer) { + return delegate.buildWriteIndex(indexContainer, indexValueSerializer); + } + + @Nonnull + @Override + public Duration objAge(@Nonnull Obj obj) { + return delegate.objAge(obj); + } + + @Override + public String realmId() { + return delegate.realmId(); + } + + @Override + public MonotonicClock monotonicClock() { + return delegate.monotonicClock(); + } + + @Override + public IdGenerator idGenerator() { + return delegate.idGenerator(); + } + + @Override + public long currentTimeMicros() { + return delegate.currentTimeMicros(); + } + + @Override + public long currentTimeMillis() { + return delegate.currentTimeMillis(); + } + + @Override + public Instant currentInstant() { + return delegate.currentInstant(); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/ExclusiveCommitSynchronizer.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/ExclusiveCommitSynchronizer.java new file mode 100644 index 0000000000..0cb55b6b41 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/ExclusiveCommitSynchronizer.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.impl.commits; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +final class ExclusiveCommitSynchronizer implements CommitSynchronizer { + private record SyncKey(String realmId, String refName) {} + + private static final Map LOCAL_COMMIT_SYNC = + new ConcurrentHashMap<>(); + + private final Semaphore semaphore = new Semaphore(1); + + static CommitSynchronizer forKey(String realmId, String refName) { + return LOCAL_COMMIT_SYNC.computeIfAbsent( + new SyncKey(realmId, refName), k -> new ExclusiveCommitSynchronizer()); + } + + @Override + public void after() { + semaphore.release(); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") // fine in this case, it'll time out + @Override + public void before(long nanosRemaining) { + try { + semaphore.tryAcquire(nanosRemaining, TimeUnit.NANOSECONDS); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/FairRetries.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/FairRetries.java new file mode 100644 index 0000000000..6918e815ca --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/FairRetries.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.impl.commits.retry; + +import org.apache.polaris.persistence.nosql.api.commit.FairRetriesType; + +interface FairRetries { + int beforeAttempt(int retries, int sentinel); + + void done(int sentinel); + + @SuppressWarnings("UnnecessaryDefault") + static FairRetries create(FairRetriesType type) { + return switch (type) { + case SLEEPING -> SleepingFairRetries.INSTANCE; + case UNFAIR -> UnfairRetries.INSTANCE; + default -> throw new IllegalStateException("Unexpected fair retries type " + type); + }; + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/RetryLoop.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/RetryLoop.java new file mode 100644 index 0000000000..ba937b40ba --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/RetryLoop.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.impl.commits.retry; + +import org.apache.polaris.ids.api.MonotonicClock; +import org.apache.polaris.persistence.nosql.api.commit.CommitException; +import org.apache.polaris.persistence.nosql.api.commit.RetryConfig; +import org.apache.polaris.persistence.nosql.api.commit.RetryTimeoutException; + +public interface RetryLoop { + static RetryLoop newRetryLoop( + RetryConfig retryConfig, MonotonicClock monotonicClock) { + return new RetryLoopImpl<>(retryConfig, monotonicClock); + } + + RetryLoop setRetryStatsConsumer(RetryStatsConsumer retryStatsConsumer); + + RESULT retryLoop(Retryable retryable) throws CommitException, RetryTimeoutException; +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/RetryLoopImpl.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/RetryLoopImpl.java new file mode 100644 index 0000000000..3929f31499 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/RetryLoopImpl.java @@ -0,0 +1,156 @@ +/* + * 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.impl.commits.retry; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static org.apache.polaris.persistence.nosql.impl.commits.retry.RetryStatsConsumer.Result.CONFLICT; +import static org.apache.polaris.persistence.nosql.impl.commits.retry.RetryStatsConsumer.Result.ERROR; +import static org.apache.polaris.persistence.nosql.impl.commits.retry.RetryStatsConsumer.Result.SUCCESS; +import static org.apache.polaris.persistence.nosql.impl.commits.retry.RetryStatsConsumer.Result.TIMEOUT; + +import java.util.concurrent.ThreadLocalRandom; +import org.apache.polaris.ids.api.MonotonicClock; +import org.apache.polaris.persistence.nosql.api.commit.CommitException; +import org.apache.polaris.persistence.nosql.api.commit.RetryConfig; +import org.apache.polaris.persistence.nosql.api.commit.RetryTimeoutException; +import org.apache.polaris.persistence.nosql.api.exceptions.UnknownOperationResultException; + +final class RetryLoopImpl implements RetryLoop { + + private final FairRetries fairRetries; + private final MonotonicClock monotonicClock; + private final long maxTime; + private final int maxRetries; + private final long maxSleep; + private long lowerBound; + private long upperBound; + private int retries; + private long sleepTime; + private RetryStatsConsumer retryStatsConsumer; + + RetryLoopImpl(RetryConfig config, MonotonicClock monotonicClock) { + this.maxTime = config.timeout().toNanos(); + this.maxRetries = config.retries(); + this.monotonicClock = monotonicClock; + this.lowerBound = config.initialSleepLower().toMillis(); + this.upperBound = config.initialSleepUpper().toMillis(); + this.maxSleep = config.maxSleep().toMillis(); + this.fairRetries = FairRetries.create(config.fairRetries()); + } + + @Override + public RetryLoop setRetryStatsConsumer(RetryStatsConsumer retryStatsConsumer) { + this.retryStatsConsumer = retryStatsConsumer; + return this; + } + + @Override + public RESULT retryLoop(Retryable retryable) + throws CommitException, RetryTimeoutException { + var timeLoopStarted = currentNanos(); + var timeoutAt = timeLoopStarted + maxTime; + var timeAttemptStarted = timeLoopStarted; + var prio = -1; // -1 means not acquired + try { + for (var attempt = 0; true; attempt++, timeAttemptStarted = currentNanos()) { + prio = fairRetries.beforeAttempt(attempt, prio); + try { + var r = retryable.attempt(timeoutAt - timeAttemptStarted); + if (r.isPresent()) { + reportEnd(SUCCESS, timeAttemptStarted); + return r.get(); + } + retryOrFail(timeLoopStarted, timeAttemptStarted, attempt); + } catch (UnknownOperationResultException e) { + retryOrFail(timeLoopStarted, timeAttemptStarted, attempt); + } + } + } catch (CommitException e) { + reportEnd(CONFLICT, timeAttemptStarted); + throw e; + } catch (RuntimeException e) { + reportEnd(ERROR, timeAttemptStarted); + throw e; + } finally { + if (prio != -1) { + fairRetries.done(prio); + } + } + } + + private void reportEnd(RetryStatsConsumer.Result result, long timeAttemptStarted) { + var c = retryStatsConsumer; + if (c != null) { + c.retryLoopFinished(result, retries, sleepTime, currentNanos() - timeAttemptStarted); + } + } + + private void retryOrFail(long timeLoopStarted, long timeAttemptStarted, int attempt) + throws RetryTimeoutException { + if (canRetry(timeLoopStarted, timeAttemptStarted)) { + return; + } + reportEnd(TIMEOUT, timeAttemptStarted); + throw new RetryTimeoutException(attempt, currentNanos() - timeLoopStarted); + } + + long currentNanos() { + return monotonicClock.nanoTime(); + } + + boolean canRetry(long timeLoopStarted, long timeAttemptStarted) { + retries++; + + var current = currentNanos(); + var totalElapsed = current - timeLoopStarted; + var attemptElapsed = timeAttemptStarted - current; + + if (maxTime < totalElapsed || maxRetries < retries) { + return false; + } + + sleepAndBackoff(totalElapsed, attemptElapsed); + + return true; + } + + private void sleepAndBackoff(long totalElapsed, long attemptElapsed) { + var lower = lowerBound; + var upper = upperBound; + var sleepMillis = lower == upper ? lower : ThreadLocalRandom.current().nextLong(lower, upper); + + // Prevent that we "sleep" too long and exceed 'maxTime' + sleepMillis = Math.min(NANOSECONDS.toMillis(Math.max(0, maxTime - totalElapsed)), sleepMillis); + + // consider the already elapsed time of the last attempt + sleepMillis = Math.max(1L, sleepMillis - NANOSECONDS.toMillis(attemptElapsed)); + + sleepTime += sleepMillis; + monotonicClock.sleepMillis(sleepMillis); + + upper = upper * 2; + long max = maxSleep; + if (upper <= max) { + lowerBound *= 2; + upperBound = upper; + } else { + upperBound = max; + } + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/RetryStatsConsumer.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/RetryStatsConsumer.java new file mode 100644 index 0000000000..875dbbcdc7 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/RetryStatsConsumer.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.impl.commits.retry; + +@FunctionalInterface +public interface RetryStatsConsumer { + void retryLoopFinished(Result result, int retries, long sleepTimeMillis, long totalDurationNanos); + + enum Result { + SUCCESS, + CONFLICT, + TIMEOUT, + ERROR, + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/Retryable.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/Retryable.java new file mode 100644 index 0000000000..4326e68bd4 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/Retryable.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.impl.commits.retry; + +import java.util.Optional; +import org.apache.polaris.persistence.nosql.api.commit.CommitException; + +@FunctionalInterface +public interface Retryable { + + /** + * Attempt a retryable operation. + * + * @return Successful attempts return a non-empty {@link Optional} containing the result. An + * {@linkplain Optional#empty() empty optional} indicates that a retry should be attempted. + * @throws CommitException Instances of this class let the whole commit operation abort. + */ + Optional attempt(long nanosRemaining) throws CommitException; +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/SleepingFairRetries.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/SleepingFairRetries.java new file mode 100644 index 0000000000..06de2bc358 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/SleepingFairRetries.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.impl.commits.retry; + +import static java.lang.Long.highestOneBit; + +import java.util.concurrent.atomic.AtomicInteger; + +final class SleepingFairRetries implements FairRetries { + + private final AtomicInteger[] prioTasks = new AtomicInteger[16]; + + static final SleepingFairRetries INSTANCE = new SleepingFairRetries(); + + SleepingFairRetries() { + for (int i = 0; i < prioTasks.length; i++) { + prioTasks[i] = new AtomicInteger(); + } + } + + @Override + public int beforeAttempt(int retries, int prevPrio) { + var prio = Math.min(prioTasks.length - 1, retries); + + if (prio != prevPrio) { + if (prevPrio >= 0) { + prioTasks[prevPrio].decrementAndGet(); + } + + prioTasks[prio].incrementAndGet(); + } + + var numHigher = 0L; + for (int i = prioTasks.length - 1; i >= prio; i--) { + numHigher += prioTasks[i].get(); + } + var n = highestOneBit(numHigher); + try { + Thread.sleep(n); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + return prio; + } + + @Override + public void done(int prio) { + prioTasks[prio].decrementAndGet(); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/UnfairRetries.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/UnfairRetries.java new file mode 100644 index 0000000000..c0853400d9 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/UnfairRetries.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.impl.commits.retry; + +final class UnfairRetries implements FairRetries { + static final UnfairRetries INSTANCE = new UnfairRetries(); + + @Override + public void done(int sentinel) {} + + @Override + public int beforeAttempt(int retries, int sentinel) { + return 0; + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/AbstractIndexElement.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/AbstractIndexElement.java new file mode 100644 index 0000000000..8bd47a971e --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/AbstractIndexElement.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.impl.indexes; + +import com.google.common.base.MoreObjects; +import com.google.errorprone.annotations.Var; +import java.util.Map; +import java.util.Objects; + +abstract class AbstractIndexElement implements IndexElement { + + @Override + public boolean equals(Object o) { + if (!(o instanceof Map.Entry other)) { + return false; + } + if (o == this) { + return true; + } + return getKey().equals(other.getKey()) && Objects.equals(getValue(), other.getValue()); + } + + @Override + public int hashCode() { + @Var var h = 5381; + h += (h << 5) + getKey().hashCode(); + var v = getValue(); + if (v != null) { + h += (h << 5) + v.hashCode(); + } + return h; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper("StoreIndexElement") + .omitNullValues() + .add("key", getKey()) + .add("content", getValue()) + .toString(); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/AbstractLayeredIndexImpl.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/AbstractLayeredIndexImpl.java new file mode 100644 index 0000000000..206d16970f --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/AbstractLayeredIndexImpl.java @@ -0,0 +1,255 @@ +/* + * 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.impl.indexes; + +import com.google.common.collect.AbstractIterator; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import org.apache.polaris.persistence.nosql.api.index.Index; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; + +/** + * Combines two {@link Index store indexes}, where one index serves as the "reference" and the other + * containing "updates". + * + *

A layered index contains all keys from both indexes. The value of a key that is present in + * both indexes will be provided from the "updates" index. + */ +abstract class AbstractLayeredIndexImpl implements IndexSpi { + + final IndexSpi reference; + final IndexSpi embedded; + + AbstractLayeredIndexImpl(IndexSpi reference, IndexSpi embedded) { + this.reference = reference; + this.embedded = embedded; + } + + @Override + public boolean hasElements() { + return embedded.hasElements() || reference.hasElements(); + } + + @Override + public boolean isModified() { + return embedded.isModified() || reference.isModified(); + } + + @Override + public void prefetchIfNecessary(Iterable keys) { + reference.prefetchIfNecessary(keys); + embedded.prefetchIfNecessary(keys); + } + + @Override + public boolean isLoaded() { + return reference.isLoaded() && embedded.isLoaded(); + } + + @Override + public List asKeyList() { + var keys = new ArrayList(); + elementIterator().forEachRemaining(elem -> keys.add(elem.getKey())); + return keys; + } + + @Override + public int estimatedSerializedSize() { + return reference.estimatedSerializedSize() + embedded.estimatedSerializedSize(); + } + + @Override + public boolean contains(IndexKey key) { + var u = embedded.getElement(key); + if (u != null) { + return u.getValue() != null; + } + var r = reference.getElement(key); + return r != null && r.getValue() != null; + } + + @Override + public boolean containsElement(@Nonnull IndexKey key) { + return embedded.containsElement(key) || reference.containsElement(key); + } + + @Nullable + @Override + public IndexElement getElement(@Nonnull IndexKey key) { + var v = embedded.getElement(key); + return v != null ? v : reference.getElement(key); + } + + @Nullable + @Override + public IndexKey first() { + var f = reference.first(); + var i = embedded.first(); + if (f == null) { + return i; + } + if (i == null) { + return f; + } + return f.compareTo(i) < 0 ? f : i; + } + + @Nullable + @Override + public IndexKey last() { + var f = reference.last(); + var i = embedded.last(); + if (f == null) { + return i; + } + if (i == null) { + return f; + } + return f.compareTo(i) > 0 ? f : i; + } + + @Nonnull + @Override + public Iterator> elementIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + return new AbstractIterator<>() { + final Iterator> referenceIter = + reference.elementIterator(lower, higher, prefetch); + final Iterator> embeddedIter = + embedded.elementIterator(lower, higher, prefetch); + + IndexElement referenceElement; + IndexElement embeddedElement; + + @Override + protected IndexElement computeNext() { + if (referenceElement == null) { + if (referenceIter.hasNext()) { + referenceElement = referenceIter.next(); + } + } + if (embeddedElement == null) { + if (embeddedIter.hasNext()) { + embeddedElement = embeddedIter.next(); + } + } + + int cmp; + if (embeddedElement == null) { + if (referenceElement == null) { + return endOfData(); + } + + cmp = -1; + } else if (referenceElement == null) { + cmp = 1; + } else { + cmp = referenceElement.getKey().compareTo(embeddedElement.getKey()); + } + + if (cmp == 0) { + referenceElement = null; + return yieldEmbedded(); + } + if (cmp < 0) { + return yieldReference(); + } + return yieldEmbedded(); + } + + private IndexElement yieldReference() { + IndexElement e = referenceElement; + referenceElement = null; + return e; + } + + private IndexElement yieldEmbedded() { + IndexElement e = embeddedElement; + embeddedElement = null; + return e; + } + }; + } + + @Nonnull + @Override + public Iterator> reverseElementIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + return new AbstractIterator<>() { + final Iterator> referenceIter = + reference.reverseElementIterator(lower, higher, prefetch); + final Iterator> embeddedIter = + embedded.reverseElementIterator(lower, higher, prefetch); + + IndexElement referenceElement; + IndexElement embeddedElement; + + @Override + protected IndexElement computeNext() { + if (referenceElement == null) { + if (referenceIter.hasNext()) { + referenceElement = referenceIter.next(); + } + } + if (embeddedElement == null) { + if (embeddedIter.hasNext()) { + embeddedElement = embeddedIter.next(); + } + } + + int cmp; + if (embeddedElement == null) { + if (referenceElement == null) { + return endOfData(); + } + + cmp = 1; + } else if (referenceElement == null) { + cmp = -1; + } else { + cmp = referenceElement.getKey().compareTo(embeddedElement.getKey()); + } + + if (cmp == 0) { + referenceElement = null; + return yieldEmbedded(); + } + if (cmp > 0) { + return yieldReference(); + } + return yieldEmbedded(); + } + + private IndexElement yieldReference() { + IndexElement e = referenceElement; + referenceElement = null; + return e; + } + + private IndexElement yieldEmbedded() { + IndexElement e = embeddedElement; + embeddedElement = null; + return e; + } + }; + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/DirectIndexElement.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/DirectIndexElement.java new file mode 100644 index 0000000000..fa2b0f17e4 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/DirectIndexElement.java @@ -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. + */ +package org.apache.polaris.persistence.nosql.impl.indexes; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.nio.ByteBuffer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; + +final class DirectIndexElement extends AbstractIndexElement { + private final IndexKey key; + private final V content; + + DirectIndexElement(@Nonnull IndexKey key, @Nullable V content) { + this.key = key; + this.content = content; + } + + @Override + public IndexKey getKey() { + return key; + } + + @Override + public V getValue() { + return content; + } + + @Override + public V setValue(V value) { + throw new UnsupportedOperationException(); + } + + @Override + public void serializeContent(IndexValueSerializer ser, ByteBuffer target) { + ser.serialize(content, target); + } + + @Override + public int contentSerializedSize(IndexValueSerializer ser) { + return ser.serializedSize(content); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/ImmutableEmptyIndexImpl.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/ImmutableEmptyIndexImpl.java new file mode 100644 index 0000000000..32f2c7b26a --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/ImmutableEmptyIndexImpl.java @@ -0,0 +1,163 @@ +/* + * 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.impl.indexes; + +import static java.util.Collections.emptyIterator; +import static java.util.Collections.emptyList; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.newStoreIndex; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.nio.ByteBuffer; +import java.util.Iterator; +import java.util.List; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; +import org.apache.polaris.persistence.varint.VarInt; + +final class ImmutableEmptyIndexImpl implements IndexSpi { + + private final IndexValueSerializer serializer; + + ImmutableEmptyIndexImpl(IndexValueSerializer serializer) { + this.serializer = serializer; + } + + @Override + public boolean hasElements() { + return false; + } + + @Override + public boolean isModified() { + return false; + } + + @Override + public void prefetchIfNecessary(Iterable keys) {} + + @Override + public boolean isLoaded() { + return true; + } + + @Override + public IndexSpi asMutableIndex() { + return newStoreIndex(serializer); + } + + @Override + public boolean isMutable() { + return false; + } + + @Override + public List> divide(int parts) { + throw unsupported(); + } + + @Override + public List> stripes() { + return emptyList(); + } + + @Override + public IndexSpi mutableStripeForKey(IndexKey key) { + throw unsupported(); + } + + @Override + public boolean add(@Nonnull IndexElement element) { + throw unsupported(); + } + + @Override + public boolean remove(@Nonnull IndexKey key) { + throw unsupported(); + } + + @Override + public boolean contains(@Nonnull IndexKey key) { + return false; + } + + @Override + public boolean containsElement(@Nonnull IndexKey key) { + return false; + } + + @Nullable + @Override + public IndexElement getElement(@Nonnull IndexKey key) { + return null; + } + + @Nullable + @Override + public IndexKey first() { + return null; + } + + @Nullable + @Override + public IndexKey last() { + return null; + } + + @Override + public List asKeyList() { + return emptyList(); + } + + @Nonnull + @Override + public Iterator> elementIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + return emptyIterator(); + } + + @Override + public Iterator> reverseElementIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + return emptyIterator(); + } + + @Override + public int estimatedSerializedSize() { + return 2; // index-version byte + VarInt.varIntLen(0) --> 1+1 + } + + @Nonnull + @Override + public ByteBuffer serialize() { + var target = ByteBuffer.allocate(estimatedSerializedSize()); + + // Serialized segment index version + target.put((byte) 1); + + VarInt.putVarInt(target, 0); + + target.flip(); + return target; + } + + private static UnsupportedOperationException unsupported() { + return new UnsupportedOperationException("Operation not supported for non-mutable indexes"); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexElement.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexElement.java new file mode 100644 index 0000000000..b19e88a3d8 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexElement.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.impl.indexes; + +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; + +interface IndexElement extends Map.Entry { + void serializeContent(IndexValueSerializer ser, ByteBuffer target); + + int contentSerializedSize(IndexValueSerializer ser); +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexImpl.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexImpl.java new file mode 100644 index 0000000000..a44c22cb51 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexImpl.java @@ -0,0 +1,982 @@ +/* + * 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.impl.indexes; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static java.util.Collections.binarySearch; +import static java.util.Objects.requireNonNull; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.deserializeKey; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; +import static org.apache.polaris.persistence.varint.VarInt.putVarInt; +import static org.apache.polaris.persistence.varint.VarInt.readVarInt; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.AbstractIterator; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.nio.BufferOverflowException; +import java.nio.ByteBuffer; +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import org.agrona.collections.Hashing; +import org.agrona.collections.Long2ObjectHashMap; +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.index.IndexValueSerializer; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; + +/** + * Implementation of {@link Index} that implements "version 1 serialization" of key-index-segments. + * + *

"Version 1" uses a diff-like encoding to compress keys and a custom var-int encoding. {@link + * IndexElement}s are serialized in their natural order. + * + *

{@link IndexKey}s are serialized by serializing each element's UTF-8 representation with a + * terminating {@code 0} byte, and the whole key terminated by a trailing {@code 0} byte. Empty key + * elements are not allowed. The total serialized size of a key must not exceed {@value + * #MAX_KEY_BYTES}. + * + *

Key serialization considers the previously serialized key - common prefixes are not + * serialized. One var-ints is used to implement a diff-ish encoding: The var-int represents the + * number of trailing bytes to strip from the previous key, then all bytes until the + * double-zero-bytes end-marker is appended. For example, if the previous key was {@code + * aaa.bbb.TableFoo} and the "current" key is {@code aaa.bbb.TableBarBaz}, the last three bytes of + * {@code aaa.bbb.TableFoo} need to be removed, resulting in {@code aaa.bbb.Table} and 6 more bytes + * ({@code BarBaz}) need to be appended to form the next key {@code aaa.bbb.TableBarBaz}. In this + * case, the var-int {@code 3} is written plus the serialized representation of the 6 bytes to + * represent {@code BarBaz} are serialized. The first serialized key is written in its entirety, + * omitting the var-int that represents the number of bytes to "strip" from the previous key. + * + *

Using var-ints to represent the number of bytes to "strip" from the previous key is more space + * efficient. It is very likely that two keys serialized after each other share a long common + * prefix, especially since keys are serialized in their natural order. + * + *

The serialized key-index does not write any length information of the individual elements or + * parts (like the {@link IndexKey} or value) to reduce the space required for serialization. + * + *

Other ideas

+ * + *

There are other possible ideas and approaches to implement a serializable index of {@link + * IndexKey} to something else: + * + *

    + *
  • Assumption (not true): Store serialized keys separate from other binary content, + * assuming that {@link IndexKey}s are compressible and the compression ratio of a set of keys + * is pretty good, unlike for example hash values, which are rather random and serialization + * likely does not benefit from compression. + *

    RESULT Experiment with >80000 words (each at least 10 chars long) for key + * elements: compression (gzip) of a key-to-commit-entry index (32 byte hashes) with + * interleaved key and value saves about 15% - the compressed ratio with keys first is only + * marginally better (approx 20%), so it is not worth the extra complexity. + *

  • Assumption (not true): Compressing the key-indexes helps with reducing database round trips + * a lot. As mentioned above, the savings of compression are around 15-22%. We can assume that + * the network traffic to the database is already compressed, so we do not save bandwidth - it + * might save one (or two) row reads of a bulk read. The savings do not feel worth the extra + * complexity. + *
  • Have another implementation that is similar to this one, but uses a {@link + * java.util.TreeMap} to build indexes, when there are many elements to add to the index + *
  • Cross-check whether the left-truncation used in the serialized representation of this + * implementation is really legit in real life. It still feels valid and legit and + * efficient. + *
  • Add some checksum (e.g. {@link java.util.zip.CRC32C}, preferred, or {@link + * java.util.zip.CRC32}) to the serialized representation? + *
+ */ +final class IndexImpl implements IndexSpi { + + static final int MAX_KEY_BYTES = 4096; + + /** + * Assume 4 additional bytes for each added entry: 2 bytes for the "strip" and 2 bytes for the + * "add" var-ints. + */ + private static final int ASSUMED_PER_ENTRY_OVERHEAD = 2 + 2; + + private static final byte CURRENT_STORE_INDEX_VERSION = 1; + + public static final Comparator> KEY_COMPARATOR = + Comparator.comparing(IndexElement::getKey); + + /** + * Serialized size of the index at the time when the {@link #IndexImpl(List, int, + * IndexValueSerializer, boolean)} constructor has been called. + * + *

This field is used to estimate the serialized size when this object is serialized + * again, including modifications. + */ + private final int originalSerializedSize; + + private int estimatedSerializedSizeDiff; + private final List> elements; + private final IndexValueSerializer serializer; + + /** + * Buffer that holds the raw serialized value of a store index. This buffer's {@link + * ByteBuffer#position()} and {@link ByteBuffer#limit()} are updated by the users of this buffer + * to perform the necessary operations. Note: {@link IndexImpl} is not thread safe as defined by + * {@link Index} + */ + private final ByteBuffer serialized; + + /** + * Used to drastically reduce the amount of 4k {@link ByteBuffer} allocations during key + * deserialization operations, which are very frequent. The JMH results alone would justify the + * use of a {@link ThreadLocal} in this case. However, those would add a "permanent GC root" to + * this class. The implemented approach is a bit more expensive, but without the drawbacks of + * {@link ThreadLocal}s. + * + *

An implementation based on an {@link java.util.ArrayDeque} was discarded, because it is way + * too expensive. + * + *

JMH results are as follows. Invoked via {@code java -jar + * persistence/nosql/persistence/impl/build/libs/polaris-persistence-nosql-impl-1.1.0-incubating-SNAPSHOT-jmh.jar + * RealisticKeyIndexImplBench -p namespaceLevels=3 -p foldersPerLevel=5 -p tablesPerNamespace=5 + * -prof gc -prof perf}. + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
BenchmarkDeque<ByteBuffer>ThreadLocal<ByteBuffer>Long2ObjectHashMapunit
RealisticKeyIndexImplBench.deserializeAdd902225µs/op (lower is better)
RealisticKeyIndexImplBench.deserializeAdd500k100k110kbytes/op (lower is better)
RealisticKeyIndexImplBench.deserializeAdd1.24.54.2insn/clk (higher is better)
RealisticKeyIndexImplBench.deserializeAddSerialize1004550µs/op (lower is better)
RealisticKeyIndexImplBench.deserializeAddSerialize530k130k130kbytes/op (lower is better)
RealisticKeyIndexImplBench.deserializeAddSerialize2.35.04.7insn/clk (higher is better)
+ */ + private static final class ScratchBuffer { + final ByteBuffer buffer; + volatile long lastUsed; + + ScratchBuffer() { + this.buffer = newKeyBuffer(); + } + } + + /** + * Maximum number of cached {@link ByteBuffer}s, equals to the number of active threads accessing + * indexes. + */ + private static final int MAX_KEY_BUFFERS = 2048; + + private static final Long2ObjectHashMap SCRATCH_KEY_BUFFERS = + new Long2ObjectHashMap<>(256, Hashing.DEFAULT_LOAD_FACTOR, true); + + private static ByteBuffer scratchKeyBuffer() { + var tid = Thread.currentThread().threadId(); + var t = System.nanoTime(); + synchronized (SCRATCH_KEY_BUFFERS) { + var buffer = SCRATCH_KEY_BUFFERS.get(tid); + if (buffer == null) { + buffer = new ScratchBuffer(); + if (SCRATCH_KEY_BUFFERS.size() == MAX_KEY_BUFFERS) { + var maxAge = Long.MAX_VALUE; + var candidate = -1L; + for (var iter = SCRATCH_KEY_BUFFERS.entrySet().iterator(); iter.hasNext(); ) { + iter.next(); + var b = iter.getValue(); + var age = Math.max(t - b.lastUsed, 0L); + if (age < maxAge) { + candidate = iter.getLongKey(); + maxAge = age; + } + } + // Intentionally remove (evict) the youngest one, as its more likely that old scratch + // buffers are in an "old GC generation", which is more costly to garbage collect. + SCRATCH_KEY_BUFFERS.remove(candidate); + } + SCRATCH_KEY_BUFFERS.put(tid, buffer); + buffer.lastUsed = t; + } else { + buffer.buffer.clear(); + } + return buffer.buffer; + } + } + + private boolean modified; + private ObjRef objRef; + + // NOTE: The implementation uses j.u.ArrayList to optimize for reads. Additions to this data + // structure are rather inefficient when elements need to be added "in the middle" of the + // 'elements' j.u.ArrayList. + + IndexImpl(IndexValueSerializer serializer) { + this(new ArrayList<>(), 2, serializer, false); + } + + private IndexImpl( + List> elements, + int originalSerializedSize, + IndexValueSerializer serializer, + boolean modified) { + this.elements = elements; + this.originalSerializedSize = originalSerializedSize; + this.serializer = serializer; + this.modified = modified; + this.serialized = null; + } + + @Override + public boolean isModified() { + return modified; + } + + @VisibleForTesting + IndexImpl setModified() { + modified = true; + return this; + } + + @Override + public ObjRef getObjId() { + return objRef; + } + + @Override + public IndexSpi setObjId(ObjRef objRef) { + this.objRef = objRef; + return this; + } + + @Override + public void prefetchIfNecessary(Iterable keys) {} + + @Override + public boolean isLoaded() { + return true; + } + + @Override + public IndexSpi asMutableIndex() { + return this; + } + + @Override + public boolean isMutable() { + return true; + } + + @Override + public List> divide(int parts) { + var elems = elements; + var size = elems.size(); + checkArgument( + parts > 0 && parts <= size, + "Number of parts %s must be greater than 0 and less or equal to number of elements %s", + parts, + size); + var partSize = size / parts; + var serializedMax = originalSerializedSize + estimatedSerializedSizeDiff; + + var result = new ArrayList>(parts); + var index = 0; + for (var i = 0; i < parts; i++) { + var end = i < parts - 1 ? index + partSize : elems.size(); + var partElements = new ArrayList<>(elements.subList(index, end)); + var part = new IndexImpl<>(partElements, serializedMax, serializer, true); + result.add(part); + index = end; + } + return result; + } + + @Override + public List> stripes() { + return List.of(this); + } + + @Override + public IndexSpi mutableStripeForKey(IndexKey key) { + return this; + } + + @Override + public boolean hasElements() { + return !elements.isEmpty(); + } + + @Override + public boolean add(@Nonnull IndexElement element) { + modified = true; + var e = elements; + var serializer = this.serializer; + var idx = search(e, element); + var elementSerializedSize = element.contentSerializedSize(serializer); + if (idx >= 0) { + // exact match, key already in the segment + var prev = e.get(idx); + + var prevSerializedSize = prev.contentSerializedSize(serializer); + estimatedSerializedSizeDiff += elementSerializedSize - prevSerializedSize; + + e.set(idx, element); + return false; + } + + estimatedSerializedSizeDiff += addElementDiff(element, elementSerializedSize); + + var insertionPoint = -idx - 1; + if (insertionPoint == e.size()) { + e.add(element); + } else { + e.add(insertionPoint, element); + } + return true; + } + + private static int addElementDiff(IndexElement element, int elementSerializedSize) { + return element.getKey().serializedSize() + ASSUMED_PER_ENTRY_OVERHEAD + elementSerializedSize; + } + + @Override + public boolean remove(@Nonnull IndexKey key) { + var e = elements; + var idx = search(e, key); + if (idx < 0) { + return false; + } + + modified = true; + + var element = e.remove(idx); + + estimatedSerializedSizeDiff -= removeSizeDiff(element); + + return true; + } + + private int removeSizeDiff(IndexElement element) { + return 2 + element.contentSerializedSize(serializer); + } + + @Override + public boolean containsElement(@Nonnull IndexKey key) { + var idx = search(elements, key); + return idx >= 0; + } + + @Override + public boolean contains(@Nonnull IndexKey key) { + var el = getElement(key); + return el != null && el.getValue() != null; + } + + @Override + public @Nullable IndexElement getElement(@Nonnull IndexKey key) { + var e = elements; + var idx = search(e, key); + if (idx < 0) { + return null; + } + return e.get(idx); + } + + @Nullable + @Override + public IndexKey first() { + var e = elements; + return e.isEmpty() ? null : e.getFirst().getKey(); + } + + @Nullable + @Override + public IndexKey last() { + var e = elements; + return e.isEmpty() ? null : e.getLast().getKey(); + } + + @Override + public @Nonnull Iterator> elementIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + var e = elements; + + if (lower == null && higher == null) { + return e.iterator(); + } + + var prefix = lower != null && lower.equals(higher); + var fromIdx = lower != null ? iteratorIndex(lower, 0) : 0; + var toIdx = !prefix && higher != null ? iteratorIndex(higher, 1) : e.size(); + + checkArgument(toIdx >= fromIdx, "'to' must be greater than 'from'"); + + e = e.subList(fromIdx, toIdx); + var base = e.iterator(); + return prefix + ? new AbstractIterator<>() { + + @Override + protected IndexElement computeNext() { + if (!base.hasNext()) { + return endOfData(); + } + var v = base.next(); + if (!v.getKey().startsWith(lower)) { + return endOfData(); + } + return v; + } + } + : base; + } + + @Override + public @Nonnull Iterator> reverseElementIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + var e = elements; + + if (lower == null && higher == null) { + return e.reversed().iterator(); + } + + var prefix = lower != null && lower.equals(higher); + checkArgument(!prefix, "reverse prefix-queries are not supported"); + var fromIdx = higher != null ? iteratorIndex(higher, 1) : e.size(); + var toIdx = lower != null ? iteratorIndex(lower, 0) : 0; + + checkArgument(toIdx <= fromIdx, "'to' must be greater than 'from'"); + + e = e.subList(toIdx, fromIdx).reversed(); + return e.iterator(); + } + + private int iteratorIndex(IndexKey from, int exactAdd) { + var fromIdx = search(elements, from); + if (fromIdx < 0) { + fromIdx = -fromIdx - 1; + } else { + fromIdx += exactAdd; + } + return fromIdx; + } + + @Override + @VisibleForTesting + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof IndexImpl)) { + return false; + } + @SuppressWarnings("unchecked") + var that = (IndexImpl) o; + return elements.equals(that.elements); + } + + @Override + @VisibleForTesting + public int hashCode() { + return elements.hashCode(); + } + + @Override + public String toString() { + var f = first(); + var l = last(); + var fk = f != null ? f.toString() : ""; + var lk = l != null ? l.toString() : ""; + return "IndexImpl{size=" + elements.size() + ", first=" + fk + ", last=" + lk + "}"; + } + + @Override + public List asKeyList() { + return new AbstractList<>() { + @Override + public IndexKey get(int index) { + return elements.get(index).getKey(); + } + + @Override + public int size() { + return elements.size(); + } + }; + } + + @Override + public int estimatedSerializedSize() { + return originalSerializedSize + estimatedSerializedSizeDiff; + } + + @Override + public @Nonnull ByteBuffer serialize() { + ByteBuffer target; + + if (serialized == null || modified) { + var elements = this.elements; + + target = ByteBuffer.allocate(estimatedSerializedSize()); + + // Serialized segment index version + target.put(CURRENT_STORE_INDEX_VERSION); + putVarInt(target, elements.size()); + + ByteBuffer previousKey = null; + + var scratchKeyBuffer = scratchKeyBuffer(); + + boolean onlyLazy; + IndexElement previous = null; + for (var el : elements) { + ByteBuffer keyBuf = null; + if (isLazyElementImpl(el)) { + var lazyEl = (LazyIndexElement) el; + // The purpose of this 'if'-branch is to determine whether it can serialize the 'IndexKey' + // by _not_ fully materializing the `IndexKey`. This is possible if (and only if!) the + // current and the previous element are `LazyStoreIndexElement`s, where the previous + // element is exactly the one that has been deserialized. + //noinspection RedundantIfStatement + if (lazyEl.prefixLen == 0 || lazyEl.previous == previous) { + // Can use the optimized serialization in `LazyStoreIndexElement` if the current + // element has no prefix of if the previously serialized element was also a + // `LazyStoreIndexElement`. In other words, no intermediate `LazyStoreIndexElement` has + // been removed and no new element has been added. + onlyLazy = true; + } else { + // This if-branch detects whether an element has been removed from the index. In that + // case, serialization has to materialize the `IndexKey` for serialization. + onlyLazy = false; + } + if (onlyLazy) { + // Key serialization via 'LazyStoreIndexElement' is much cheaper (CPU and heap) than + // having to first materialize and then serialize it. + keyBuf = lazyEl.serializeKey(scratchKeyBuffer, previousKey); + } + } else { + onlyLazy = false; + } + + if (!onlyLazy) { + // Either 'el' is not a 'LazyStoreIndexElement' or the previous element of a + // 'LazyStoreIndexElement' is not suitable (see above). + keyBuf = serializeIndexKeyString(el.getKey(), scratchKeyBuffer); + } + + previousKey = serializeKey(keyBuf, previousKey, target); + el.serializeContent(serializer, target); + previous = el; + } + + target = target.flip(); + } else { + target = serializedThreadSafe().position(0).limit(originalSerializedSize); + } + + return target; + } + + // IntelliJ warns "Condition 'el.getClass() == LazyStoreIndexElement.class' is always 'false'", + // which is a false positive (see below as well). + @SuppressWarnings("ConstantValue") + private boolean isLazyElementImpl(IndexElement el) { + return el.getClass() == LazyIndexElement.class; + } + + private ByteBuffer serializeKey(ByteBuffer keyBuf, ByteBuffer previousKey, ByteBuffer target) { + var keyPos = keyBuf.position(); + if (previousKey != null) { + var mismatch = previousKey.mismatch(keyBuf); + checkState(mismatch != -1, "Previous and current keys must not be equal"); + var strip = previousKey.remaining() - mismatch; + putVarInt(target, strip); + keyBuf.position(keyPos + mismatch); + } else { + previousKey = newKeyBuffer(); + } + target.put(keyBuf); + + previousKey.clear(); + keyBuf.position(keyPos); + previousKey.put(keyBuf).flip(); + + return previousKey; + } + + static IndexSpi deserializeStoreIndex(ByteBuffer serialized, IndexValueSerializer ser) { + return new IndexImpl<>(serialized, ser); + } + + /** + * Private constructor handling deserialization, required to instantiate the inner {@link + * LazyIndexElement} class. + */ + private IndexImpl(ByteBuffer serialized, IndexValueSerializer ser) { + var version = serialized.get(); + checkArgument(version == 1, "Unsupported serialized representation of KeyIndexSegment"); + + var elements = new ArrayList>(readVarInt(serialized)); + + var first = true; + var previousKeyLen = 0; + LazyIndexElement predecessor = null; + LazyIndexElement previous = null; + + while (serialized.remaining() > 0) { + var strip = first ? 0 : readVarInt(serialized); + first = false; + + var prefixLen = previousKeyLen - strip; + checkArgument(prefixLen >= 0, "prefixLen must be >= 0"); + var keyOffset = serialized.position(); + IndexKey.skip(serialized); // skip key + var valueOffset = serialized.position(); + ser.skip(serialized); // skip content/value + var endOffset = serialized.position(); + + var keyPartLen = valueOffset - keyOffset; + var totalKeyLen = prefixLen + keyPartLen; + + predecessor = cutPredecessor(predecessor, prefixLen, previous); + + // 'prefixLen==0' means that the current key represents the "full" key. + // It has no predecessor that would be needed to re-construct (aka materialize) the full key. + var elementPredecessor = prefixLen > 0 ? predecessor : null; + var element = + new LazyIndexElement( + elementPredecessor, previous, keyOffset, prefixLen, valueOffset, endOffset); + if (elementPredecessor == null) { + predecessor = element; + } else if (predecessor.prefixLen > prefixLen) { + predecessor = element; + } + elements.add(element); + + previous = element; + previousKeyLen = totalKeyLen; + } + + this.elements = elements; + this.serializer = ser; + this.originalSerializedSize = serialized.position(); + this.serialized = serialized.duplicate().clear(); + } + + /** + * Identifies the earliest suitable predecessor, which is a very important step during + * deserialization, because otherwise the chain of predecessors (to the element having a {@code + * prefixLen==0}) can easily become very long in the order of many thousands "hops", which makes + * key materialization overly expensive. + */ + private LazyIndexElement cutPredecessor( + LazyIndexElement predecessor, int prefixLen, LazyIndexElement previous) { + if (predecessor != null) { + if (predecessor.prefixLen < prefixLen) { + // If the current element's prefixLen is higher, let the current element's predecessor point + // to the previous element. + predecessor = previous; + } else { + // Otherwise, find the predecessor that has "enough" data. Without this step, the chain of + // predecessors would become extremely long. + for (var p = predecessor; ; p = p.predecessor) { + if (p == null || p.prefixLen < prefixLen) { + break; + } + predecessor = p; + } + } + } + return predecessor; + } + + private final class LazyIndexElement extends AbstractIndexElement { + /** + * Points to the predecessor (in index order) that has a required part of the index-key needed + * to deserialize. In other words, if multiple index-elements have the same {@code prefixLen}, + * this one points to the first one (in index order), because referencing the "intermediate" + * predecessors in-between would yield no part of the index-key to be re-constructed. + * + *

This fields holds the "earliest" predecessor in deserialization order, as determined by + * {@link #cutPredecessor(LazyIndexElement, int, LazyIndexElement)}. + * + *

Example:

+     *  IndexElement #0 { prefixLen = 0, key = "aaa", predecessor = null }
+     *  IndexElement #1 { prefixLen = 2, key = "aab", predecessor = #0 }
+     *  IndexElement #2 { prefixLen = 2, key = "aac", predecessor = #0 }
+     *  IndexElement #3 { prefixLen = 1, key = "abb", predecessor = #0 }
+     *  IndexElement #4 { prefixLen = 0, key = "bbb", predecessor = null }
+     *  IndexElement #5 { prefixLen = 2, key = "bbc", predecessor = #4 }
+     *  IndexElement #6 { prefixLen = 3, key = "bbcaaa", predecessor = #5 }
+     * 
+ */ + final LazyIndexElement predecessor; + + /** + * The previous element in the order of deserialization. This is needed later during + * serialization. + */ + final LazyIndexElement previous; + + /** Number of bytes for this element's key that are held by its predecessor(s). */ + final int prefixLen; + + /** Position in {@link IndexImpl#serialized} at which this index-element's key part starts. */ + final int keyOffset; + + /** Position in {@link IndexImpl#serialized} at which this index-element's value starts. */ + final int valueOffset; + + /** + * Position in {@link #serialized} pointing to the first byte after this element's key + * and value. + */ + final int endOffset; + + /** The materialized key or {@code null}. */ + private IndexKey key; + + /** The materialized content or {@code null}. */ + private V content; + + private boolean hasContent; + + LazyIndexElement( + LazyIndexElement predecessor, + LazyIndexElement previous, + int keyOffset, + int prefixLen, + int valueOffset, + int endOffset) { + this.predecessor = predecessor; + this.previous = previous; + this.keyOffset = keyOffset; + this.prefixLen = prefixLen; + this.valueOffset = valueOffset; + this.endOffset = endOffset; + } + + ByteBuffer serializeKey(ByteBuffer keySerBuffer, ByteBuffer previousKey) { + keySerBuffer.clear(); + if (previousKey != null) { + var limitSave = previousKey.limit(); + keySerBuffer.put(previousKey.limit(prefixLen).position(0)); + previousKey.limit(limitSave).position(0); + } + + return keySerBuffer + .put(serializedNotThreadSafe().limit(valueOffset).position(keyOffset)) + .flip(); + } + + private IndexKey materializeKey() { + var serialized = serializedThreadSafe(); + + var suffix = serialized.limit(valueOffset).position(keyOffset); + + var preLen = prefixLen; + var keyBuffer = + preLen > 0 + ? prefixKey(serialized, this, preLen).position(preLen).put(suffix).flip() + : suffix; + return deserializeKey(keyBuffer); + } + + private ByteBuffer prefixKey(ByteBuffer serialized, LazyIndexElement me, int remaining) { + var keyBuffer = scratchKeyBuffer(); + + // This loop could be easier written using recursion. However, recursion is way more expensive + // than this loop. Since this code is on a very hot code path, it is worth it. + for (var e = me.predecessor; e != null; e = e.predecessor) { + if (e.key != null) { + // In case the current 'e' has its key already materialized, use that one to construct the + // prefix for "our" key. + var limitSave = keyBuffer.limit(); + try { + // Call 'putString' with the parameter 'shortened==true' to instruct the function to + // expect buffer overruns and handle those gracefully. + e.key.serializeNoFail(keyBuffer.limit(remaining)); + } finally { + keyBuffer.limit(limitSave); + } + break; + } + + var prefixLen = e.prefixLen; + var take = remaining - prefixLen; + if (take > 0) { + remaining -= take; + + for (int src = e.keyOffset, dst = e.prefixLen; take-- > 0; src++, dst++) { + keyBuffer.put(dst, serialized.get(src)); + } + } + } + + return keyBuffer; + } + + @Override + public void serializeContent(IndexValueSerializer ser, ByteBuffer target) { + target.put(serializedNotThreadSafe().limit(endOffset).position(valueOffset)); + } + + @Override + public int contentSerializedSize(IndexValueSerializer ser) { + return endOffset - valueOffset; + } + + @Override + public IndexKey getKey() { + var k = key; + if (k == null) { + k = key = materializeKey(); + } + return k; + } + + @Override + public V getValue() { + var c = content; + if (c == null) { + if (!hasContent) { + c = + content = + serializer.deserialize( + serializedThreadSafe().limit(endOffset).position(valueOffset)); + hasContent = true; + } + } + return c; + } + + @Override + public V setValue(V value) { + throw new UnsupportedOperationException(); + } + + @Override + public String toString() { + var k = key; + var c = content; + if (k != null && c != null) { + return super.toString(); + } + + var sb = new StringBuilder("LazyStoreIndexElement("); + if (k != null) { + sb.append("key=").append(k); + } else { + sb.append("keyOffset=").append(keyOffset).append(", prefixLen=").append(prefixLen); + } + + if (c != null) { + sb.append(", content=").append(c); + } else { + sb.append(", valueOffset=").append(valueOffset).append(" endOffset=").append(endOffset); + } + + return sb.toString(); + } + } + + @VisibleForTesting + static ByteBuffer newKeyBuffer() { + return ByteBuffer.allocate(MAX_KEY_BYTES); + } + + /** + * Non-thread-safe variant to retrieve {@link #serialized}. Used in these scenarios, which are not + * thread safe by contract: + * + *
    + *
  • serializing a modified + *
+ */ + private ByteBuffer serializedNotThreadSafe() { + return requireNonNull(serialized); + } + + /** + * Thread-safe variant to retrieve {@link #serialized}. Used in these scenarios, which are not + * thread safe by contract: + * + *
    + *
  • serializing a non-modified index + *
  • lazy materialization of a key (deserialization) + *
  • lazy materialization of a value (deserialization) + *
+ */ + private ByteBuffer serializedThreadSafe() { + return requireNonNull(serialized).duplicate(); + } + + private static int search(List> e, @Nonnull IndexKey key) { + // Need a StoreIndexElement for the sake of 'binarySearch()' (the content value isn't used) + return search(e, indexElement(key, "")); + } + + private static int search(List> e, IndexElement element) { + return binarySearch(e, element, KEY_COMPARATOR); + } + + static ByteBuffer serializeIndexKeyString(IndexKey key, ByteBuffer keySerializationBuffer) { + keySerializationBuffer.clear(); + try { + return key.serialize(keySerializationBuffer).flip(); + } catch (BufferOverflowException e) { + throw new IllegalArgumentException("Serialized key too big"); + } + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexLoader.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexLoader.java new file mode 100644 index 0000000000..27beb10a18 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexLoader.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.impl.indexes; + +import jakarta.annotation.Nonnull; + +@FunctionalInterface +interface IndexLoader { + static IndexLoader notLoading() { + return indexes -> { + throw new UnsupportedOperationException("not loading"); + }; + } + + /** + * Load the given indexes, {@code null} elements might be present in the {@code indexes} + * parameters. + * + * @return a new array of the same length as the input array, with the non-{@code null} elements + * of the input parameter set to the loaded indexes. Loaded indexes may or may not be the same + * (input) instance. + */ + @Nonnull + IndexSpi[] loadIndexes(@Nonnull IndexSpi[] indexes); +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexSpi.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexSpi.java new file mode 100644 index 0000000000..9d415ffe59 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexSpi.java @@ -0,0 +1,197 @@ +/* + * 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.impl.indexes; + +import static java.util.Objects.requireNonNull; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.nio.ByteBuffer; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +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.index.ModifiableIndex; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; + +interface IndexSpi extends ModifiableIndex { + + /** + * Returns {@code true}, if there is at least one element. + * + *

Note: omitting {@code isEmpty()}, because that might be added to {@link Index}, but {@code + * isEmpty()} would have to respect {@code null} values from {@link IndexElement#getValue()}. + */ + boolean hasElements(); + + /** + * Adds a new element to the index. + * + * @param element element to add + * @return {@code true}, if the key did not exist in the index before + */ + boolean add(@Nonnull IndexElement element); + + /** + * Convenience around {@link #add(IndexElement)}. + * + * @param key key to add + * @param value value to add + * @return {@code true}, if the key did not exist in the index before + */ + @Override + default boolean put(@Nonnull IndexKey key, @Nonnull V value) { + requireNonNull(key, "key must not be null"); + requireNonNull(value, "value must not be null"); + return add(indexElement(key, value)); + } + + /** + * Retrieve the index element for a key, including remove-sentinels. + * + * @param key key to retrieve the element for + * @return element or {@code null}, if the key does not exist. Does also return remove-sentinels, + * the element for remove sentinels is not {@code null}, the value for those is {@code null}. + */ + @Nullable + IndexElement getElement(@Nonnull IndexKey key); + + /** + * Check whether the index contains the given key, with a non-{@code null} or a {@code null} + * value. + */ + boolean containsElement(@Nonnull IndexKey key); + + /** + * Get a list of all {@link IndexKey}s in this index - do not use this method in + * production code against lazy or striped or layered indexes, because it will trigger index load + * operations. + * + *

The returned list does return keys for remove-sentinels in the embedded index, the element + * for remove sentinels is not {@code null}, the value for those is {@code null}. + * + *

Producing the list of all keys can be quite expensive, prevent using this function. + */ + List asKeyList(); + + /** + * Convenience around {@link #getElement(IndexKey)}. + * + * @param key key to retrieve + * @return value or {@code null} + */ + @Nullable + @Override + default V get(@Nonnull IndexKey key) { + var elem = getElement(key); + return elem != null ? elem.getValue() : null; + } + + @Nullable + IndexKey first(); + + @Nullable + IndexKey last(); + + default Iterator> elementIterator() { + return elementIterator(null, null, false); + } + + Iterator> elementIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch); + + default Iterator> reverseElementIterator() { + return reverseElementIterator(null, null, false); + } + + Iterator> reverseElementIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch); + + @Nonnull + @Override + default Iterator> iterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + return new Iterator<>() { + final Iterator> delegate = elementIterator(lower, higher, prefetch); + + @Override + public boolean hasNext() { + return delegate.hasNext(); + } + + @Override + public Map.Entry next() { + return delegate.next(); + } + }; + } + + @Nonnull + @Override + default Iterator> reverseIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + return new Iterator<>() { + final Iterator> delegate = reverseElementIterator(lower, higher, prefetch); + + @Override + public boolean hasNext() { + return delegate.hasNext(); + } + + @Override + public Map.Entry next() { + return delegate.next(); + } + }; + } + + boolean isModified(); + + boolean isLoaded(); + + default ObjRef getObjId() { + throw new UnsupportedOperationException(); + } + + default IndexSpi setObjId(ObjRef objRef) { + throw new UnsupportedOperationException(); + } + + IndexSpi asMutableIndex(); + + boolean isMutable(); + + List> divide(int parts); + + List> stripes(); + + IndexSpi mutableStripeForKey(IndexKey key); + + /** + * Get the estimated serialized size of this structure. The returned value is likely + * higher than the real serialized size, as produced by {@link #serialize()}, but the returned + * value must never be smaller than the real required serialized size. + */ + int estimatedSerializedSize(); + + @Nonnull + ByteBuffer serialize(); +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexStripeObj.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexStripeObj.java new file mode 100644 index 0000000000..272713c46f --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexStripeObj.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.impl.indexes; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.nio.ByteBuffer; +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 = ImmutableIndexStripeObj.class) +@JsonDeserialize(as = ImmutableIndexStripeObj.class) +public interface IndexStripeObj extends Obj { + ObjType TYPE = new IndexStripeObjType(); + + ByteBuffer index(); + + static IndexStripeObj indexStripeObj(long id, ByteBuffer index) { + return ImmutableIndexStripeObj.builder().id(id).index(index).build(); + } + + @Override + default ObjType type() { + return TYPE; + } + + final class IndexStripeObjType extends AbstractObjType { + public IndexStripeObjType() { + super("ix", "Index Stripe", IndexStripeObj.class); + } + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexesInternal.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexesInternal.java new file mode 100644 index 0000000000..36af9c1a43 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexesInternal.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.impl.indexes; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.lang.String.format; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexLoader.notLoading; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +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.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.index.IndexStripe; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; + +final class IndexesInternal { + private IndexesInternal() {} + + static IndexSpi emptyImmutableIndex(IndexValueSerializer serializer) { + return new ImmutableEmptyIndexImpl<>(serializer); + } + + static IndexSpi newStoreIndex(IndexValueSerializer serializer) { + return new IndexImpl<>(serializer); + } + + static IndexSpi deserializeStoreIndex(ByteBuffer serialized, IndexValueSerializer ser) { + return IndexImpl.deserializeStoreIndex(serialized.duplicate(), ser); + } + + /** + * Returns a {@link Index} that calls the supplier upon the first use, useful to load an index + * only when it is required. + */ + static IndexSpi lazyStoreIndex( + Supplier> supplier, IndexKey firstKey, IndexKey lastKey) { + return new LazyIndexImpl<>(supplier, firstKey, lastKey); + } + + /** + * Combined read-only view of two indexes, values of the {@code updates} index take precedence. + * + *

Used to construct a combined view to an "embedded" and "referenced / spilled out" index. + */ + static IndexSpi layeredIndex(IndexSpi reference, IndexSpi updates) { + return new ReadOnlyLayeredIndexImpl<>(reference, updates); + } + + /** + * Produces a new, striped index from the given segments. + * + *

Used to produce a "reference / spilled out" index that is going to be persisted in multiple + * database rows/objects. + */ + @SuppressWarnings("unchecked") + static IndexSpi indexFromStripes(List> stripes) { + var stripesArr = stripes.toArray(new IndexSpi[0]); + var firstLastKeys = new IndexKey[stripes.size() * 2]; + for (var i = 0; i < stripes.size(); i++) { + var stripe = stripes.get(i); + var first = stripe.first(); + var last = stripe.last(); + checkArgument(first != null && last != null, "Stipe #%s must not be empty, but is empty", i); + firstLastKeys[i * 2] = first; + firstLastKeys[i * 2 + 1] = last; + } + + return new StripedIndexImpl(stripesArr, firstLastKeys, notLoading()); + } + + /** + * Instantiates a striped index using the given stripes. The order of the stripes must represent + * the natural order of the keys, which means that all keys in any stripe must be smaller than the + * keys of any following stripe. + * + *

Used to represent a "spilled out" index loaded from the backend database. + * + * @param stripes the nested indexes, there must be at least two + * @param firstLastKeys the first+last keys of the {@code stripes} + * @param indexLoader the bulk-loading-capable lazy-index-loader + */ + @SuppressWarnings("unchecked") + static IndexSpi indexFromStripes( + @Nonnull List> stripes, + @Nonnull List firstLastKeys, + @Nonnull IndexLoader indexLoader) { + var stripesArr = stripes.toArray(new IndexSpi[0]); + var firstLastKeysArr = firstLastKeys.toArray(new IndexKey[0]); + return new StripedIndexImpl(stripesArr, firstLastKeysArr, indexLoader); + } + + static IndexElement indexElement(IndexKey key, V content) { + return new DirectIndexElement<>(key, content); + } + + @Nullable + static IndexSpi referenceIndex( + @Nullable IndexContainer indexContainer, + @Nonnull Persistence persistence, + @Nonnull IndexValueSerializer indexValueSerializer) { + if (indexContainer != null) { + var commitStripes = indexContainer.stripes(); + if (!commitStripes.isEmpty()) { + return referenceIndexFromStripes(persistence, commitStripes, indexValueSerializer); + } + } + + return null; + } + + private static IndexSpi referenceIndexFromStripes( + @Nonnull Persistence persistence, + @Nonnull List indexStripes, + @Nonnull IndexValueSerializer indexValueSerializer) { + var stripes = new ArrayList>(indexStripes.size()); + var firstLastKeys = new ArrayList(indexStripes.size() * 2); + + @SuppressWarnings("unchecked") + IndexSpi[] loaded = new IndexSpi[indexStripes.size()]; + + for (var i = 0; i < indexStripes.size(); i++) { + var s = indexStripes.get(i); + var idx = i; + stripes.add( + lazyStoreIndex( + () -> { + IndexSpi l = loaded[idx]; + if (l == null) { + l = loadIndexSegment(persistence, s.segment(), indexValueSerializer); + loaded[idx] = l; + } + return l; + }, + s.firstKey(), + s.lastKey()) + .setObjId(s.segment())); + firstLastKeys.add(s.firstKey()); + firstLastKeys.add(s.lastKey()); + } + if (stripes.size() == 1) { + return stripes.getFirst(); + } + + IndexLoader indexLoader = + indexesToLoad -> { + checkArgument(indexesToLoad.length == loaded.length); + ObjRef[] ids = new ObjRef[indexesToLoad.length]; + for (int i = 0; i < indexesToLoad.length; i++) { + var idx = indexesToLoad[i]; + if (idx != null) { + var segmentId = idx.getObjId(); + if (segmentId != null) { + ids[i] = idx.getObjId(); + } + } + } + IndexSpi[] indexes = loadIndexSegments(persistence, ids, indexValueSerializer); + for (var i = 0; i < indexes.length; i++) { + var idx = indexes[i]; + if (idx != null) { + loaded[i] = idx; + } + } + return indexes; + }; + + return indexFromStripes(stripes, firstLastKeys, indexLoader); + } + + private static IndexSpi[] loadIndexSegments( + @Nonnull Persistence persistence, + @Nonnull ObjRef[] indexes, + @Nonnull IndexValueSerializer indexValueSerializer) { + var objs = persistence.fetchMany(IndexStripeObj.class, indexes); + @SuppressWarnings("unchecked") + IndexSpi[] r = new IndexSpi[indexes.length]; + for (var i = 0; i < objs.length; i++) { + var index = objs[i]; + if (index != null) { + r[i] = deserializeStoreIndex(index.index(), indexValueSerializer).setObjId(indexes[i]); + } + } + return r; + } + + private static IndexSpi loadIndexSegment( + @Nonnull Persistence persistence, + @Nonnull ObjRef indexId, + @Nonnull IndexValueSerializer indexValueSerializer) { + var index = persistence.fetch(indexId, IndexStripeObj.class); + if (index == null) { + throw new IllegalStateException( + format("Commit %s references a reference index, which does not exist", indexId)); + } + return deserializeStoreIndex(index.index(), indexValueSerializer).setObjId(indexId); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexesProvider.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexesProvider.java new file mode 100644 index 0000000000..a9e088cf41 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexesProvider.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.impl.indexes; + +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.deserializeStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.emptyImmutableIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.newStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.referenceIndex; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +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.IndexContainer; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; +import org.apache.polaris.persistence.nosql.api.index.UpdatableIndex; + +/** Factory methods for store indexes. */ +public final class IndexesProvider { + private IndexesProvider() {} + + public static Index buildReadIndex( + @Nullable IndexContainer indexContainer, + @Nonnull Persistence persistence, + @Nonnull IndexValueSerializer indexValueSerializer) { + if (indexContainer != null) { + var embedded = deserializeStoreIndex(indexContainer.embedded(), indexValueSerializer); + var reference = referenceIndex(indexContainer, persistence, indexValueSerializer); + return new ReadOnlyIndex<>( + reference != null ? new ReadOnlyLayeredIndexImpl<>(reference, embedded) : embedded); + } + + return new ReadOnlyIndex<>(emptyImmutableIndex(indexValueSerializer)); + } + + public static UpdatableIndex buildWriteIndex( + @Nullable IndexContainer indexContainer, + @Nonnull Persistence persistence, + @Nonnull IndexValueSerializer indexValueSerializer) { + var embedded = + indexContainer != null + ? deserializeStoreIndex(indexContainer.embedded(), indexValueSerializer) + : newStoreIndex(indexValueSerializer); + var reference = referenceIndex(indexContainer, persistence, indexValueSerializer); + if (reference == null) { + reference = new ImmutableEmptyIndexImpl<>(indexValueSerializer); + } + + return new UpdatableIndexImpl<>( + indexContainer, + embedded, + reference, + persistence.params(), + persistence::generateId, + indexValueSerializer); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/LazyIndexImpl.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/LazyIndexImpl.java new file mode 100644 index 0000000000..d2a7da22af --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/LazyIndexImpl.java @@ -0,0 +1,199 @@ +/* + * 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.impl.indexes; + +import static org.apache.polaris.persistence.nosql.impl.indexes.SupplyOnce.memoize; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.nio.ByteBuffer; +import java.util.Iterator; +import java.util.List; +import java.util.function.Supplier; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; + +final class LazyIndexImpl implements IndexSpi { + + private final Supplier> loader; + private boolean loaded; + private ObjRef objRef; + private final IndexKey firstKey; + private final IndexKey lastKey; + + LazyIndexImpl(Supplier> supplier, IndexKey firstKey, IndexKey lastKey) { + this.firstKey = firstKey; + this.lastKey = lastKey; + this.loader = + memoize( + () -> { + try { + return supplier.get(); + } finally { + loaded = true; + } + }); + } + + private IndexSpi loaded() { + return loader.get(); + } + + @Override + public ObjRef getObjId() { + return objRef; + } + + @Override + public IndexSpi setObjId(ObjRef objRef) { + this.objRef = objRef; + return this; + } + + @Override + public boolean isModified() { + if (!loaded) { + return false; + } + return loaded().isModified(); + } + + @Override + public void prefetchIfNecessary(Iterable keys) { + loaded().prefetchIfNecessary(keys); + } + + @Override + public boolean isLoaded() { + return loaded; + } + + @Override + public IndexSpi asMutableIndex() { + return loaded().asMutableIndex(); + } + + @Override + public boolean isMutable() { + if (!loaded) { + return false; + } + return loaded().isMutable(); + } + + @Override + public List> divide(int parts) { + return loaded().divide(parts); + } + + @Override + public List> stripes() { + return loaded().stripes(); + } + + @Override + public IndexSpi mutableStripeForKey(IndexKey key) { + return loaded().mutableStripeForKey(key); + } + + @Override + public boolean hasElements() { + return loaded().hasElements(); + } + + @Override + public int estimatedSerializedSize() { + return loaded().estimatedSerializedSize(); + } + + @Override + public boolean add(@Nonnull IndexElement element) { + return loaded().add(element); + } + + @Override + public boolean remove(@Nonnull IndexKey key) { + return loaded().remove(key); + } + + @Override + public boolean contains(@Nonnull IndexKey key) { + if (!loaded && (key.equals(firstKey) || key.equals(lastKey))) { + return true; + } + return loaded().contains(key); + } + + @Override + public boolean containsElement(@Nonnull IndexKey key) { + if (!loaded && (key.equals(firstKey) || key.equals(lastKey))) { + return true; + } + return loaded().containsElement(key); + } + + @Override + @Nullable + public IndexElement getElement(@Nonnull IndexKey key) { + return loaded().getElement(key); + } + + @Override + @Nullable + public IndexKey first() { + if (loaded || firstKey == null) { + return loaded().first(); + } + return firstKey; + } + + @Override + @Nullable + public IndexKey last() { + if (loaded || lastKey == null) { + return loaded().last(); + } + return lastKey; + } + + @Override + public List asKeyList() { + return loaded().asKeyList(); + } + + @Override + @Nonnull + public Iterator> elementIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + return loaded().elementIterator(lower, higher, prefetch); + } + + @Override + @Nonnull + public Iterator> reverseElementIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + return loaded().reverseElementIterator(lower, higher, prefetch); + } + + @Override + @Nonnull + public ByteBuffer serialize() { + return loaded().serialize(); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/ReadOnlyIndex.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/ReadOnlyIndex.java new file mode 100644 index 0000000000..df7354333b --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/ReadOnlyIndex.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.persistence.nosql.impl.indexes; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.util.Iterator; +import java.util.Map; +import org.apache.polaris.persistence.nosql.api.index.Index; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; + +final class ReadOnlyIndex implements Index { + private final Index delegate; + + ReadOnlyIndex(@Nonnull Index delegate) { + this.delegate = delegate; + } + + @Override + public void prefetchIfNecessary(Iterable keys) { + delegate.prefetchIfNecessary(keys); + } + + @Override + public boolean contains(@Nonnull IndexKey key) { + return delegate.contains(key); + } + + @Nullable + @Override + public V get(@Nonnull IndexKey key) { + return delegate.get(key); + } + + @Nonnull + @Override + public Iterator> iterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + return delegate.iterator(lower, higher, prefetch); + } + + @Nonnull + @Override + public Iterator> reverseIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + return delegate.reverseIterator(lower, higher, prefetch); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/ReadOnlyLayeredIndexImpl.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/ReadOnlyLayeredIndexImpl.java new file mode 100644 index 0000000000..c888340f49 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/ReadOnlyLayeredIndexImpl.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.impl.indexes; + +import static java.util.Collections.singletonList; + +import jakarta.annotation.Nonnull; +import java.nio.ByteBuffer; +import java.util.List; +import org.apache.polaris.persistence.nosql.api.index.Index; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; + +/** + * Combines two {@link Index store indexes}, where one index serves as the "reference" and the other + * containing "updates". + * + *

A layered index contains all keys from both indexes. The value of a key that is present in + * both indexes will be provided from the "embedded" index. + */ +final class ReadOnlyLayeredIndexImpl extends AbstractLayeredIndexImpl { + + ReadOnlyLayeredIndexImpl(IndexSpi reference, IndexSpi embedded) { + super(reference, embedded); + } + + @Override + public IndexSpi asMutableIndex() { + throw unsupported(); + } + + @Override + public boolean isMutable() { + return false; + } + + @Override + public List> divide(int parts) { + throw unsupported(); + } + + @Override + public List> stripes() { + return singletonList(this); + } + + @Override + public IndexSpi mutableStripeForKey(IndexKey key) { + throw unsupported(); + } + + @Nonnull + @Override + public ByteBuffer serialize() { + throw unsupported(); + } + + @Override + public boolean add(@Nonnull IndexElement element) { + throw unsupported(); + } + + @Override + public boolean remove(@Nonnull IndexKey key) { + throw unsupported(); + } + + private static UnsupportedOperationException unsupported() { + return new UnsupportedOperationException("Layered indexes do not support this operation"); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/StripedIndexImpl.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/StripedIndexImpl.java new file mode 100644 index 0000000000..948ef79a37 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/StripedIndexImpl.java @@ -0,0 +1,361 @@ +/* + * 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.impl.indexes; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Arrays.asList; +import static java.util.Arrays.binarySearch; + +import com.google.common.collect.AbstractIterator; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.function.Predicate; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; + +final class StripedIndexImpl implements IndexSpi { + + private final IndexSpi[] stripes; + private final IndexKey[] firstLastKeys; + private final IndexLoader indexLoader; + + StripedIndexImpl( + @Nonnull IndexSpi[] stripes, + @Nonnull IndexKey[] firstLastKeys, + IndexLoader indexLoader) { + checkArgument(stripes.length > 1); + checkArgument( + stripes.length * 2 == firstLastKeys.length, + "Number of stripes (%s) must match number of first-last-keys (%s)", + stripes.length, + firstLastKeys.length); + for (IndexKey firstLastKey : firstLastKeys) { + checkArgument(firstLastKey != null, "firstLastKey must not contain any null element"); + } + this.stripes = stripes; + this.firstLastKeys = firstLastKeys; + this.indexLoader = indexLoader; + } + + @Override + public boolean isModified() { + for (IndexSpi stripe : stripes) { + if (stripe.isModified()) { + return true; + } + } + return false; + } + + @Override + public void prefetchIfNecessary(Iterable keys) { + var stripes = this.stripes; + @SuppressWarnings("unchecked") + IndexSpi[] indexesToLoad = new IndexSpi[stripes.length]; + + var cnt = 0; + for (var key : keys) { + var idx = stripeForExistingKey(key); + if (idx == -1) { + continue; + } + var index = stripes[idx]; + if (!index.isLoaded()) { + indexesToLoad[idx] = index; + cnt++; + } + } + + if (cnt > 0) { + loadStripes(indexesToLoad); + } + } + + private void loadStripes(int firstIndex, int lastIndex) { + var stripes = this.stripes; + @SuppressWarnings("unchecked") + IndexSpi[] indexesToLoad = new IndexSpi[stripes.length]; + + var cnt = 0; + for (var idx = firstIndex; idx <= lastIndex; idx++) { + var index = stripes[idx]; + if (!index.isLoaded()) { + indexesToLoad[idx] = index; + cnt++; + } + } + + if (cnt > 0) { + loadStripes(indexesToLoad); + } + } + + private void loadStripes(IndexSpi[] indexesToLoad) { + var stripes = this.stripes; + + var loadedIndexes = indexLoader.loadIndexes(indexesToLoad); + for (int i = 0; i < loadedIndexes.length; i++) { + var loaded = loadedIndexes[i]; + if (loaded != null) { + stripes[i] = loaded; + } + } + } + + @Override + public boolean isLoaded() { + var stripes = this.stripes; + for (var stripe : stripes) { + if (stripe.isLoaded()) { + return true; + } + } + return false; + } + + @Override + public IndexSpi asMutableIndex() { + return this; + } + + @Override + public boolean isMutable() { + return true; + } + + @Override + public List> divide(int parts) { + throw new UnsupportedOperationException("Striped indexes cannot be further divided"); + } + + @Override + public List> stripes() { + return asList(stripes); + } + + @Override + public IndexSpi mutableStripeForKey(IndexKey key) { + var i = indexForKey(key); + var stripe = stripes[i]; + if (!stripe.isMutable()) { + stripes[i] = stripe = stripe.asMutableIndex(); + } + return stripe; + } + + @Override + public boolean hasElements() { + // can safely assume true here + return true; + } + + @Override + public int estimatedSerializedSize() { + var sum = 0; + var stripes = this.stripes; + for (var stripe : stripes) { + sum += stripe.estimatedSerializedSize(); + } + return sum; + } + + @Override + public boolean contains(@Nonnull IndexKey key) { + var i = stripeForExistingKey(key); + if (i == -1) { + return false; + } + return stripes[i].contains(key); + } + + @Override + public boolean containsElement(@Nonnull IndexKey key) { + var i = stripeForExistingKey(key); + if (i == -1) { + return false; + } + return stripes[i].containsElement(key); + } + + @Nullable + @Override + public IndexElement getElement(@Nonnull IndexKey key) { + var i = stripeForExistingKey(key); + if (i == -1) { + return null; + } + return stripes[i].getElement(key); + } + + @Nullable + @Override + public IndexKey first() { + return stripes[0].first(); + } + + @Nullable + @Override + public IndexKey last() { + var s = stripes; + return s[s.length - 1].last(); + } + + @Override + public List asKeyList() { + var r = new ArrayList(); + elementIterator().forEachRemaining(elem -> r.add(elem.getKey())); + return r; + } + + @Nonnull + @Override + public Iterator> elementIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + var s = stripes; + + var prefix = lower != null && lower.equals(higher); + var start = lower == null ? 0 : indexForKey(lower); + var stop = prefix || higher == null ? s.length - 1 : indexForKey(higher); + + if (prefetch) { + loadStripes(start, stop); + } + + Predicate endCheck = + prefix + ? k -> !k.startsWith(lower) + : (higher != null ? k -> higher.compareTo(k) < 0 : k -> false); + + return new AbstractIterator<>() { + int stripe = start; + Iterator> current = s[start].elementIterator(lower, null, prefetch); + + @Override + protected IndexElement computeNext() { + while (true) { + var has = current.hasNext(); + if (has) { + var v = current.next(); + if (endCheck.test(v.getKey())) { + return endOfData(); + } + return v; + } + + stripe++; + if (stripe > stop) { + return endOfData(); + } + current = s[stripe].elementIterator(); + } + } + }; + } + + @Nonnull + @Override + public Iterator> reverseElementIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + var s = stripes; + + var prefix = lower != null && lower.equals(higher); + checkArgument(!prefix, "prefix-queries not supported for reverse-iteration"); + var start = lower == null ? 0 : indexForKey(lower); + var stop = higher == null ? s.length - 1 : indexForKey(higher); + + if (prefetch) { + loadStripes(start, stop); + } + + Predicate endCheck = (lower != null ? k -> lower.compareTo(k) > 0 : k -> false); + + return new AbstractIterator<>() { + int stripe = stop; + Iterator> current = s[stop].reverseElementIterator(null, higher, prefetch); + + @Override + protected IndexElement computeNext() { + while (true) { + var has = current.hasNext(); + if (has) { + var v = current.next(); + if (endCheck.test(v.getKey())) { + return endOfData(); + } + return v; + } + + stripe--; + if (stripe < start) { + return endOfData(); + } + current = s[stripe].reverseElementIterator(); + } + } + }; + } + + @Nonnull + @Override + public ByteBuffer serialize() { + throw unsupported(); + } + + @Override + public boolean add(@Nonnull IndexElement element) { + return mutableStripeForKey(element.getKey()).add(element); + } + + @Override + public boolean remove(@Nonnull IndexKey key) { + return mutableStripeForKey(key).remove(key); + } + + private int stripeForExistingKey(IndexKey key) { + var firstLast = firstLastKeys; + var i = binarySearch(firstLast, key); + if (i < 0) { + i = -i - 1; + if ((i & 1) == 0) { + return -1; + } + } + if (i == firstLast.length) { + return -1; + } + i /= 2; + return i; + } + + private int indexForKey(IndexKey key) { + var firstLast = firstLastKeys; + var i = binarySearch(firstLast, key); + if (i < 0) { + i = -i - 1; + } + return Math.min(i / 2, (firstLast.length / 2) - 1); + } + + private static UnsupportedOperationException unsupported() { + return new UnsupportedOperationException("Striped indexes do not support this operation"); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/SupplyOnce.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/SupplyOnce.java new file mode 100644 index 0000000000..02db8aed91 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/SupplyOnce.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.impl.indexes; + +import java.util.function.Supplier; + +/** + * This is an internal utility class, which provides a non-synchronized, not thread-safe + * {@link Supplier} that memoizes returned value but also a thrown exception. + */ +final class SupplyOnce { + + private SupplyOnce() {} + + static Supplier memoize(Supplier loader) { + return new NonLockingSupplyOnce<>(loader); + } + + private static final class NonLockingSupplyOnce implements Supplier { + private int loaded; + private Object result; + private final Supplier loader; + + private NonLockingSupplyOnce(Supplier loader) { + this.loader = loader; + } + + @Override + @SuppressWarnings("unchecked") + public T get() { + return switch (loaded) { + case 1 -> (T) result; + case 2 -> throw (RuntimeException) result; + case 0 -> load(); + default -> throw new IllegalStateException(); + }; + } + + private T load() { + try { + loaded = 1; + T obj = loader.get(); + result = obj; + return obj; + } catch (RuntimeException re) { + loaded = 2; + result = re; + throw re; + } + } + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/UpdatableIndexImpl.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/UpdatableIndexImpl.java new file mode 100644 index 0000000000..31995542ef --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/UpdatableIndexImpl.java @@ -0,0 +1,344 @@ +/* + * 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.impl.indexes; + +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.primitives.Ints.checkedCast; +import static java.util.Objects.requireNonNull; +import static org.apache.polaris.persistence.nosql.api.index.IndexStripe.indexStripe; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexStripeObj.indexStripeObj; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.emptyImmutableIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.newStoreIndex; + +import com.google.common.collect.AbstractIterator; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.LongSupplier; +import org.apache.polaris.persistence.nosql.api.PersistenceParams; +import org.apache.polaris.persistence.nosql.api.index.ImmutableIndexContainer; +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.IndexValueSerializer; +import org.apache.polaris.persistence.nosql.api.index.UpdatableIndex; +import org.apache.polaris.persistence.nosql.api.obj.Obj; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +final class UpdatableIndexImpl extends AbstractLayeredIndexImpl implements UpdatableIndex { + + private static final Logger LOGGER = LoggerFactory.getLogger(UpdatableIndexImpl.class); + + private final IndexContainer indexContainer; + private final PersistenceParams params; + private final LongSupplier idGenerator; + private final IndexValueSerializer serializer; + private boolean finalized; + + UpdatableIndexImpl( + @Nullable IndexContainer indexContainer, + @Nonnull IndexSpi embedded, + @Nonnull IndexSpi reference, + @Nonnull PersistenceParams params, + @Nonnull LongSupplier idGenerator, + @Nonnull IndexValueSerializer serializer) { + super(reference, embedded); + this.indexContainer = indexContainer; + this.params = params; + this.idGenerator = idGenerator; + this.serializer = serializer; + } + + @Override + public IndexContainer toIndexed( + @Nonnull String prefix, @Nonnull BiConsumer persistObj) { + checkNotFinalized(); + finalized = true; + + var indexContainerBuilder = ImmutableIndexContainer.builder(); + + if (embedded.estimatedSerializedSize() > params.maxEmbeddedIndexSize().asLong()) { + // The serialized representation of the embedded index is probably bigger than the configured + // limit. Spill out the embedded index. + spillOutEmbedded(prefix, persistObj, indexContainerBuilder); + } else { + // The serialized embedded index fits into the configured limit, no need to spill out. + // But tweak the embedded index as necessary. + if (this.indexContainer != null) { + indexContainerBuilder.from(this.indexContainer); + } + noSpillOutUpdateEmbeddedIndex(indexContainerBuilder); + } + + return indexContainerBuilder.build(); + } + + @Override + public Optional> toOptionalIndexed( + @Nonnull String prefix, @Nonnull BiConsumer persistObj) { + var indexContainer = toIndexed(prefix, persistObj); + return indexContainer.embedded().remaining() == 0 && indexContainer.stripes().isEmpty() + ? Optional.empty() + : Optional.of(indexContainer); + } + + private void noSpillOutUpdateEmbeddedIndex(ImmutableIndexContainer.Builder indexedBuilder) { + var newEmbedded = newStoreIndex(serializer); + for (var elemIter = embedded.elementIterator(); elemIter.hasNext(); ) { + var elem = elemIter.next(); + var key = elem.getKey(); + var value = elem.getValue(); + if (value == null) { + if (reference.contains(key)) { + // 'key' is being removed, only keep it in the embedded index, if it is required to shadow + // a value from the reference index. + newEmbedded.add(indexElement(key, null)); + } + } else { + newEmbedded.add(elem); + } + } + indexedBuilder.embedded(newEmbedded.serialize()); + } + + private void spillOutEmbedded( + String prefix, + @Nonnull BiConsumer persistObj, + ImmutableIndexContainer.Builder indexedBuilder) { + var mutableReference = reference.asMutableIndex(); + + // Prefetch existing stripes that contain keys in the 'embedded' index + prefetchExistingStripes(this.indexContainer, mutableReference); + + // Update affected stripes + updateAffectedStripes(mutableReference); + + // Set the new, empty embedded index + indexedBuilder.embedded(emptyImmutableIndex(serializer).serialize()); + + // Collect the surviving stripes - stripes will and must be ordered by the first/last keys over + // all stripes. + var survivingStripes = collectSurvivingStripes(mutableReference); + + // Add the surviving stripes to the builder and push to be persisted. + survivingStripes(prefix, persistObj, indexedBuilder, survivingStripes); + } + + private void prefetchExistingStripes( + IndexContainer indexContainer, IndexSpi mutableReference) { + checkState(embedded instanceof IndexImpl); + if (indexContainer != null && !indexContainer.stripes().isEmpty()) { + // 'embedded' is a 'StoreIndexImpl' and it's 'asKeyList' is cheap + mutableReference.prefetchIfNecessary(embedded.asKeyList()); + } + } + + private void updateAffectedStripes(IndexSpi mutableReference) { + for (var elemIter = embedded.elementIterator(); elemIter.hasNext(); ) { + var indexElement = elemIter.next(); + var key = indexElement.getKey(); + var value = indexElement.getValue(); + var stripe = mutableReference.mutableStripeForKey(key); + + if (value == null) { + // Embedded remove marker, remove the shadowed element from the index stripe and don't keep + // it in the embedded index + stripe.remove(key); + } else { + // Add/update element stripe + stripe.add(indexElement); + } + } + } + + private List> collectSurvivingStripes(IndexSpi mutableReference) { + var survivingStripes = new ArrayList>(); + for (var stripe : mutableReference.stripes()) { + // Only use stripe if it (still) has elements. + if (stripe.hasElements()) { + var serSize = stripe.estimatedSerializedSize(); + var desiredSplits = checkedCast(serSize / params.maxIndexStripeSize().asLong() + 1); + if (desiredSplits > 1) { + // The stripe became too big, needs to be split further + LOGGER.debug( + "Splitting index stripe {}, modified={}, into {} parts", + stripe.getObjId(), + stripe.isModified(), + desiredSplits); + survivingStripes.addAll(stripe.divide(desiredSplits)); + } else { + LOGGER.debug( + "Keeping index stripe {}, modified={}", stripe.getObjId(), stripe.isModified()); + survivingStripes.add(stripe); + } + } else { + LOGGER.debug("Omitting empty index stripe {}", stripe.getObjId()); + } + } + return survivingStripes; + } + + private void survivingStripes( + String prefix, + BiConsumer persistObj, + ImmutableIndexContainer.Builder indexedBuilder, + List> survivingStripes) { + for (var stripe : survivingStripes) { + var first = requireNonNull(stripe.first()); + var last = stripe.last(); + ObjRef id; + if (stripe.isModified()) { + var obj = indexStripeObj(idGenerator.getAsLong(), stripe.serialize()); + // Persist updated stripes + persistObj.accept(first.toSafeString(prefix), obj); + id = objRef(obj); + } else { + id = requireNonNull(stripe.getObjId()); + } + LOGGER.debug( + "Adding stripe {} for '{}' .. '{}', modified = {}", id, first, last, stripe.isModified()); + indexedBuilder.addStripe(indexStripe(first, last, id)); + } + } + + // Mutators + + @Override + public boolean add(@Nonnull IndexElement element) { + checkNotFinalized(); + var added = embedded.add(element); + if (added) { + return !reference.containsElement(element.getKey()); + } + return false; + } + + @Override + public boolean remove(@Nonnull IndexKey key) { + checkNotFinalized(); + var updExisting = embedded.getElement(key); + if (updExisting != null && updExisting.getValue() == null) { + // removal sentinel is already present, do nothing + return false; + } + + var refExisting = reference.containsElement(key); + if (refExisting) { + // Key exists in the reference index, add a "removal sentinel" + embedded.add(indexElement(key, null)); + return true; + } + + if (updExisting != null) { + // Key does not exist in the reference index, remove it + embedded.remove(key); + return true; + } + + // Key is not present at all + return false; + } + + // readers + + @Override + public boolean containsElement(@Nonnull IndexKey key) { + checkNotFinalized(); + return super.containsElement(key); + } + + @Nullable + @Override + public IndexElement getElement(@Nonnull IndexKey key) { + checkNotFinalized(); + return super.getElement(key); + } + + @Nonnull + @Override + public ByteBuffer serialize() { + throw unsupported(); + } + + @Override + public IndexSpi asMutableIndex() { + return this; + } + + @Override + public boolean isMutable() { + return true; + } + + @Override + public List> divide(int parts) { + throw unsupported(); + } + + @Override + public List> stripes() { + throw unsupported(); + } + + @Override + public IndexSpi mutableStripeForKey(IndexKey key) { + throw unsupported(); + } + + private static UnsupportedOperationException unsupported() { + return new UnsupportedOperationException("Updatable indexes do not support this operation"); + } + + @Nonnull + @Override + public Iterator> elementIterator( + @Nullable IndexKey lower, @Nullable IndexKey higher, boolean prefetch) { + checkNotFinalized(); + return new AbstractIterator<>() { + final Iterator> base = + UpdatableIndexImpl.super.elementIterator(lower, higher, prefetch); + + @Override + protected IndexElement computeNext() { + while (true) { + if (!base.hasNext()) { + return endOfData(); + } + var elem = base.next(); + if (elem.getValue() == null) { + continue; + } + return elem; + } + } + }; + } + + private void checkNotFinalized() { + checkState(!finalized, "UpdatableIndex.toIndexed() already called"); + } +} diff --git a/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/package-info.java b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/package-info.java new file mode 100644 index 0000000000..d49c3c64d4 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/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. + */ +/** Index implementation, do not directly use the types in this package. */ +package org.apache.polaris.persistence.nosql.impl.indexes; diff --git a/persistence/nosql/persistence/impl/src/main/resources/META-INF/beans.xml b/persistence/nosql/persistence/impl/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/persistence/impl/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType b/persistence/nosql/persistence/impl/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType new file mode 100644 index 0000000000..2c7fe9e5c2 --- /dev/null +++ b/persistence/nosql/persistence/impl/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.impl.indexes.IndexStripeObj$IndexStripeObjType diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/TestMultiByteArrayInputStream.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/TestMultiByteArrayInputStream.java new file mode 100644 index 0000000000..23b11df2fe --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/TestMultiByteArrayInputStream.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.impl; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +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 TestMultiByteArrayInputStream { + @InjectSoftAssertions protected SoftAssertions soft; + + @ParameterizedTest + @MethodSource + public void reads(List bytes, String expected) throws IOException { + soft.assertThat(new MultiByteArrayInputStream(bytes)).hasContent(expected); + + try (var in = new MultiByteArrayInputStream(bytes)) { + soft.assertThat(new String(in.readAllBytes(), UTF_8)).isEqualTo(expected); + } + + try (var in = new MultiByteArrayInputStream(bytes)) { + var out = new ByteArrayOutputStream(); + var c = 0; + while ((c = in.read()) != -1) { + out.write(c); + } + soft.assertThat(out.toString(UTF_8)).isEqualTo(expected); + } + + var buf = new byte[3]; + try (var in = new MultiByteArrayInputStream(bytes)) { + var out = new ByteArrayOutputStream(); + var rd = 0; + while ((rd = in.read(buf)) != -1) { + out.write(buf, 0, rd); + } + soft.assertThat(out.toString(UTF_8)).isEqualTo(expected); + } + } + + static Stream reads() { + return Stream.of( + arguments(List.of("a".getBytes(UTF_8)), "a"), + // + arguments(List.of("a".getBytes(UTF_8), "b".getBytes(UTF_8), "c".getBytes(UTF_8)), "abc"), + // + arguments( + List.of( + new byte[0], + "a2345678901234567890123456".getBytes(UTF_8), + new byte[0], + "b".getBytes(UTF_8), + new byte[0], + "c2345678901234567890123456".getBytes(UTF_8)), + "a2345678901234567890123456bc2345678901234567890123456"), + // + arguments( + List.of( + new byte[0], + ("a".repeat(123)).getBytes(UTF_8), + ("b".repeat(77)).getBytes(UTF_8), + ("c".repeat(13)).getBytes(UTF_8)), + "a".repeat(123) + "b".repeat(77) + "c".repeat(13)), + // + arguments(List.of(), "") + // + ); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/DefaultCachingObj.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/DefaultCachingObj.java new file mode 100644 index 0000000000..76a19b558c --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/DefaultCachingObj.java @@ -0,0 +1,46 @@ +/* + * 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.impl.cache; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +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.immutables.value.Value; + +@Value.Immutable +@JsonSerialize(as = ImmutableDefaultCachingObj.class) +@JsonDeserialize(as = ImmutableDefaultCachingObj.class) +interface DefaultCachingObj extends Obj { + ObjType TYPE = new DefaultCachingObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + String value(); + + final class DefaultCachingObjType extends AbstractObjType { + public DefaultCachingObjType() { + super("test-default-caching", "default caching", DefaultCachingObj.class); + } + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/DynamicCachingObj.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/DynamicCachingObj.java new file mode 100644 index 0000000000..42fd7774e2 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/DynamicCachingObj.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.impl.cache; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.util.function.LongSupplier; +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.immutables.value.Value; + +@Value.Immutable +@JsonSerialize(as = ImmutableDynamicCachingObj.class) +@JsonDeserialize(as = ImmutableDynamicCachingObj.class) +interface DynamicCachingObj extends Obj { + ObjType TYPE = new DynamicCachingObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + long thatExpireTimestamp(); + + final class DynamicCachingObjType extends AbstractObjType { + public DynamicCachingObjType() { + super("dyn-cache", "dynamic caching", DynamicCachingObj.class); + } + + @Override + public long cachedObjectExpiresAtMicros(Obj obj, LongSupplier clockMicros) { + return ((DynamicCachingObj) obj).thatExpireTimestamp() + clockMicros.getAsLong(); + } + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/NegativeCachingObj.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/NegativeCachingObj.java new file mode 100644 index 0000000000..b741868604 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/NegativeCachingObj.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.impl.cache; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.util.function.LongSupplier; +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.immutables.value.Value; + +@Value.Immutable +@JsonSerialize(as = ImmutableNegativeCachingObj.class) +@JsonDeserialize(as = ImmutableNegativeCachingObj.class) +interface NegativeCachingObj extends Obj { + ObjType TYPE = new NegativeCachingObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + final class NegativeCachingObjType extends AbstractObjType { + public NegativeCachingObjType() { + super("test-negative-caching", "negative caching", NegativeCachingObj.class); + } + + @Override + public long negativeCacheExpiresAtMicros(LongSupplier clockMicros) { + return CACHE_UNLIMITED; + } + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/NonCachingObj.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/NonCachingObj.java new file mode 100644 index 0000000000..76e121902a --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/NonCachingObj.java @@ -0,0 +1,46 @@ +/* + * 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.impl.cache; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +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.immutables.value.Value; + +@Value.Immutable +@JsonSerialize(as = ImmutableNonCachingObj.class) +@JsonDeserialize(as = ImmutableNonCachingObj.class) +interface NonCachingObj extends Obj { + ObjType TYPE = new NonCachingObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + String value(); + + final class NonCachingObjType extends AbstractObjType.AbstractUncachedObjType { + public NonCachingObjType() { + super("test-non-caching", "non caching", NonCachingObj.class); + } + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheConfig.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheConfig.java new file mode 100644 index 0000000000..081fa4fc72 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheConfig.java @@ -0,0 +1,87 @@ +/* + * 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.impl.cache; + +import static org.apache.polaris.persistence.nosql.api.cache.CacheConfig.INVALID_REFERENCE_NEGATIVE_TTL; +import static org.apache.polaris.persistence.nosql.api.cache.CacheConfig.INVALID_REFERENCE_TTL; + +import java.time.Duration; +import org.apache.polaris.misc.types.memorysize.MemorySize; +import org.apache.polaris.persistence.nosql.api.cache.CacheConfig; +import org.apache.polaris.persistence.nosql.api.cache.CacheSizing; +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; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestCacheConfig { + @InjectSoftAssertions protected SoftAssertions soft; + + @Test + public void allDefaults() { + soft.assertThatCode(() -> defaultBuilder().build()).doesNotThrowAnyException(); + } + + @Test + public void referenceCaching() { + soft.assertThatCode(() -> defaultBuilder().referenceTtl(Duration.ofMinutes(1)).build()) + .doesNotThrowAnyException(); + soft.assertThatCode( + () -> + defaultBuilder() + .referenceTtl(Duration.ofMinutes(1)) + .referenceNegativeTtl(Duration.ofMinutes(1)) + .build()) + .doesNotThrowAnyException(); + soft.assertThatIllegalStateException() + .isThrownBy(() -> defaultBuilder().referenceTtl(Duration.ofMinutes(-1)).build()) + .withMessage(INVALID_REFERENCE_TTL); + soft.assertThatIllegalStateException() + .isThrownBy( + () -> + defaultBuilder() + .referenceTtl(Duration.ZERO) + .referenceNegativeTtl(Duration.ofMinutes(1)) + .build()) + .withMessage(INVALID_REFERENCE_NEGATIVE_TTL); + soft.assertThatIllegalStateException() + .isThrownBy( + () -> + defaultBuilder() + .referenceTtl(Duration.ofMinutes(1)) + .referenceNegativeTtl(Duration.ofMinutes(-1)) + .build()) + .withMessage(INVALID_REFERENCE_NEGATIVE_TTL); + soft.assertThatIllegalStateException() + .isThrownBy( + () -> + defaultBuilder() + .referenceTtl(Duration.ofMinutes(1)) + .referenceNegativeTtl(Duration.ofMinutes(0)) + .build()) + .withMessage(INVALID_REFERENCE_NEGATIVE_TTL); + } + + private static CacheConfig.BuildableCacheConfig.Builder defaultBuilder() { + return CacheConfig.BuildableCacheConfig.builder() + .sizing(CacheSizing.builder().fixedSize(MemorySize.ofMega(1)).build()); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheExpiration.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheExpiration.java new file mode 100644 index 0000000000..e7ccf430a0 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheExpiration.java @@ -0,0 +1,128 @@ +/* + * 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.impl.cache; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.cacheKeyValueObjRead; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.polaris.misc.types.memorysize.MemorySize; +import org.apache.polaris.persistence.nosql.api.cache.CacheConfig; +import org.apache.polaris.persistence.nosql.api.cache.CacheSizing; +import org.apache.polaris.persistence.nosql.api.obj.ImmutableSimpleTestObj; +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.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestCacheExpiration { + @InjectSoftAssertions protected SoftAssertions soft; + + protected String realmId; + + @BeforeEach + protected void setUp() { + realmId = "42"; + } + + @Test + public void cachingObjectsExpiration() { + var currentTime = new AtomicLong(1234L); + + var backend = + new CaffeineCacheBackend( + CacheConfig.BuildableCacheConfig.builder() + .sizing(CacheSizing.builder().fixedSize(MemorySize.ofMega(8)).build()) + .clockNanos(() -> MICROSECONDS.toNanos(currentTime.get())) + .build(), + Optional.empty()); + + var id = 100L; + + var defaultCachingObj = ImmutableDefaultCachingObj.builder().id(id++).value("def").build(); + var nonCachingObj = ImmutableNonCachingObj.builder().id(id++).value("foo").build(); + var dynamicCachingObj = + ImmutableDynamicCachingObj.builder().id(id++).thatExpireTimestamp(2L).build(); + var stdObj = ImmutableSimpleTestObj.builder().id(id).text("foo").build(); + + backend.put(realmId, defaultCachingObj); + backend.put(realmId, nonCachingObj); + backend.put(realmId, dynamicCachingObj); + backend.put(realmId, stdObj); + + var cacheMap = backend.cache.asMap(); + + soft.assertThat(cacheMap) + .doesNotContainKey(cacheKeyValueObjRead(realmId, objRef(nonCachingObj))) + .containsKey(cacheKeyValueObjRead(realmId, objRef(dynamicCachingObj))) + .containsKey(cacheKeyValueObjRead(realmId, objRef(defaultCachingObj))) + .containsKey(cacheKeyValueObjRead(realmId, objRef(stdObj))) + .hasSize(3); + + soft.assertThat(backend.get(realmId, objRef(nonCachingObj))).isNull(); + soft.assertThat(backend.get(realmId, objRef(dynamicCachingObj))).isEqualTo(dynamicCachingObj); + soft.assertThat(backend.get(realmId, objRef(defaultCachingObj))).isEqualTo(defaultCachingObj); + soft.assertThat(backend.get(realmId, objRef(stdObj))).isEqualTo(stdObj); + + soft.assertThat(cacheMap) + .doesNotContainKey(cacheKeyValueObjRead(realmId, objRef(nonCachingObj))) + .containsKey(cacheKeyValueObjRead(realmId, objRef(dynamicCachingObj))) + .containsKey(cacheKeyValueObjRead(realmId, objRef(defaultCachingObj))) + .containsKey(cacheKeyValueObjRead(realmId, objRef(stdObj))) + .hasSize(3); + + // increment clock by one - "dynamic" object should still be present + + currentTime.addAndGet(1); + + soft.assertThat(backend.get(realmId, objRef(nonCachingObj))).isNull(); + soft.assertThat(backend.get(realmId, objRef(dynamicCachingObj))).isEqualTo(dynamicCachingObj); + soft.assertThat(backend.get(realmId, objRef(defaultCachingObj))).isEqualTo(defaultCachingObj); + soft.assertThat(backend.get(realmId, objRef(stdObj))).isEqualTo(stdObj); + + soft.assertThat(cacheMap) + .doesNotContainKey(cacheKeyValueObjRead(realmId, objRef(nonCachingObj))) + .containsKey(cacheKeyValueObjRead(realmId, objRef(dynamicCachingObj))) + .containsKey(cacheKeyValueObjRead(realmId, objRef(defaultCachingObj))) + .containsKey(cacheKeyValueObjRead(realmId, objRef(stdObj))) + .hasSize(3); + + // increment clock by one again - "dynamic" object should go away + + currentTime.addAndGet(1); + + soft.assertThat(backend.get(realmId, objRef(nonCachingObj))).isNull(); + soft.assertThat(backend.get(realmId, objRef(dynamicCachingObj))).isNull(); + soft.assertThat(backend.get(realmId, objRef(defaultCachingObj))).isEqualTo(defaultCachingObj); + soft.assertThat(backend.get(realmId, objRef(stdObj))).isEqualTo(stdObj); + + soft.assertThat(cacheMap) + .doesNotContainKey(cacheKeyValueObjRead(realmId, objRef(nonCachingObj))) + .doesNotContainKey(cacheKeyValueObjRead(realmId, objRef(dynamicCachingObj))) + .containsKey(cacheKeyValueObjRead(realmId, objRef(defaultCachingObj))) + .containsKey(cacheKeyValueObjRead(realmId, objRef(stdObj))); + // note: Caffeine's cache-map incorrectly reports a size of 3 here, although the map itself only + // returns the only left object + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheKeys.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheKeys.java new file mode 100644 index 0000000000..02ac9af928 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheKeys.java @@ -0,0 +1,144 @@ +/* + * 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.impl.cache; + +import static org.apache.polaris.persistence.nosql.api.Realms.SYSTEM_REALM_ID; +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.impl.cache.CaffeineCacheBackend.cacheKeyObjId; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.cacheKeyValueNegative; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.cacheKeyValueObj; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.cacheKeyValueObjRead; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.cacheKeyValueReference; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.cacheKeyValueReferenceRead; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.api.obj.ImmutableGenericObj; +import org.apache.polaris.persistence.nosql.api.ref.Reference; +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 TestCacheKeys { + @InjectSoftAssertions protected SoftAssertions soft; + + @ParameterizedTest + @MethodSource + public void referenceKeys(String realmId, String referenceName) { + var read1 = cacheKeyValueReferenceRead(realmId, referenceName); + var read2 = cacheKeyValueReferenceRead(realmId, referenceName); + soft.assertThat(read2).isEqualTo(read1); + soft.assertThat(read2.hashCode()).isEqualTo(read1.hashCode()); + soft.assertThat(read1).isEqualTo(read2); + + var readDiffRealm = cacheKeyValueReferenceRead(SYSTEM_REALM_ID, referenceName); + soft.assertThat(readDiffRealm).isNotEqualTo(read1); + + var readDiffName = cacheKeyValueReferenceRead(realmId, referenceName + 'a'); + soft.assertThat(readDiffName).isNotEqualTo(read1); + + var write1 = + cacheKeyValueReference( + realmId, + Reference.builder().name(referenceName).previousPointers().createdAtMicros(42L).build(), + 0L); + soft.assertThat(write1).isEqualTo(read1); + soft.assertThat(read1).isEqualTo(write1); + var write2 = + cacheKeyValueReference( + realmId, + Reference.builder().name(referenceName).previousPointers().createdAtMicros(42L).build(), + 2L); + soft.assertThat(write2).isEqualTo(read1); + soft.assertThat(read1).isEqualTo(write2); + soft.assertThat(write2).isEqualTo(write1); + soft.assertThat(write1).isEqualTo(write2); + } + + static Stream referenceKeys() { + return Stream.of( + arguments("realm", ""), arguments("realm", "ref"), arguments("", "ref"), arguments("", "")); + } + + @ParameterizedTest + @MethodSource + public void objKeys(String realmId, String type, long id) { + var read1 = cacheKeyValueObjRead(realmId, objRef(type, id)); + var read2 = cacheKeyValueObjRead(realmId, objRef(type, id, 1)); + soft.assertThat(read2).isEqualTo(read1); + soft.assertThat(read2.hashCode()).isEqualTo(read1.hashCode()); + soft.assertThat(read1).isEqualTo(read2); + + var negative1 = cacheKeyValueNegative(realmId, cacheKeyObjId(objRef(type, id)), 0L); + soft.assertThat(negative1).isEqualTo(read1); + soft.assertThat(read1).isEqualTo(negative1); + var negative2 = cacheKeyValueNegative(realmId, cacheKeyObjId(objRef(type, id, 1)), 123L); + soft.assertThat(negative2).isEqualTo(read1); + soft.assertThat(read1).isEqualTo(negative2); + + var obj1 = + cacheKeyValueObj( + realmId, + ImmutableGenericObj.builder() + .type(objTypeById(type)) + .id(id) + .numParts(42) + .createdAtMicros(123) + .build(), + 0L); + var obj2 = + cacheKeyValueObj( + realmId, + ImmutableGenericObj.builder() + .type(objTypeById(type)) + .id(id) + .numParts(1) + .createdAtMicros(123) + .build(), + 0L); + var obj3 = + cacheKeyValueObj( + realmId, + ImmutableGenericObj.builder() + .type(objTypeById(type)) + .id(id) + .numParts(1) + .createdAtMicros(42) + .build(), + 0L); + soft.assertThat(obj2).isEqualTo(obj1); + soft.assertThat(obj1).isEqualTo(obj2); + soft.assertThat(obj3).isEqualTo(obj1); + soft.assertThat(obj1).isEqualTo(obj3); + soft.assertThat(obj1).isEqualTo(read1); + soft.assertThat(read1).isEqualTo(obj1); + soft.assertThat(obj1).isEqualTo(negative1); + soft.assertThat(negative1).isEqualTo(obj1); + } + + static Stream objKeys() { + return Stream.of(arguments("realm", "type1", 42L), arguments("", "x", 43L)); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheOvershoot.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheOvershoot.java new file mode 100644 index 0000000000..eb9cc8bcc8 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheOvershoot.java @@ -0,0 +1,149 @@ +/* + * 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.impl.cache; + +import static java.util.concurrent.CompletableFuture.delayedExecutor; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.METER_CACHE_ADMIT_CAPACITY; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.METER_CACHE_CAPACITY; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.METER_CACHE_REJECTED_WEIGHT; +import static org.apache.polaris.persistence.nosql.impl.cache.CaffeineCacheBackend.METER_CACHE_WEIGHT; +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.base.Strings; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.Optional; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.apache.polaris.misc.types.memorysize.MemorySize; +import org.apache.polaris.persistence.nosql.api.cache.CacheConfig; +import org.apache.polaris.persistence.nosql.api.cache.CacheSizing; +import org.apache.polaris.persistence.nosql.api.obj.SimpleTestObj; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.RepeatedTest; +import org.junitpioneer.jupiter.RetryingTest; + +public class TestCacheOvershoot { + + /** This simulates the production setup. */ + @RetryingTest(minSuccess = 5, maxAttempts = 10) + // It may happen that the admitted weight is actually exceeded. Allow some failed iterations. + public void testCacheOvershootDirectEviction() throws Exception { + testCacheOvershoot(Runnable::run, true); + } + + /** This test illustrates delayed eviction, leading to more heap usage than admitted. */ + @RepeatedTest(10) // consider the first repetition as a warmup (C1/C2) + @Disabled("not production like") + public void testCacheOvershootDelayedEviction() throws Exception { + // Production uses Runnable::run, but that lets this test sometimes run way too + // long, so we introduce some delay to simulate the case that eviction cannot keep up. + testCacheOvershoot(t -> delayedExecutor(2, TimeUnit.MILLISECONDS).execute(t), false); + } + + private void testCacheOvershoot(Executor evictionExecutor, boolean direct) throws Exception { + var meterRegistry = new SimpleMeterRegistry(); + + var config = + CacheConfig.BuildableCacheConfig.builder() + .sizing( + CacheSizing.builder() + .fixedSize(MemorySize.ofMega(4)) + .cacheCapacityOvershoot(0.1d) + .build()) + .build(); + var cache = new CaffeineCacheBackend(config, Optional.of(meterRegistry), evictionExecutor); + + var metersByName = + meterRegistry.getMeters().stream() + .collect(Collectors.toMap(m -> m.getId().getName(), Function.identity(), (a, b) -> a)); + assertThat(metersByName) + .containsKeys(METER_CACHE_WEIGHT, METER_CACHE_ADMIT_CAPACITY, METER_CACHE_REJECTED_WEIGHT); + var meterWeightReported = (Gauge) metersByName.get(METER_CACHE_WEIGHT); + var meterAdmittedCapacity = (Gauge) metersByName.get(METER_CACHE_ADMIT_CAPACITY); + var meterCapacity = (Gauge) metersByName.get(METER_CACHE_CAPACITY); + var meterRejectedWeight = (DistributionSummary) metersByName.get(METER_CACHE_REJECTED_WEIGHT); + + var maxWeight = cache.capacityBytes(); + var admitWeight = cache.admitWeight(); + + var str = Strings.repeat("a", 4096); + + var idGen = new AtomicLong(); + + var numThreads = 8; + + for (int i = 0; i < maxWeight / 5000; i++) { + cache.put("repo", SimpleTestObj.builder().id(idGen.incrementAndGet()).text(str).build()); + } + + assertThat(cache.currentWeightReported()).isLessThanOrEqualTo(maxWeight); + assertThat(cache.rejections()).isEqualTo(0L); + assertThat(meterWeightReported.value()).isGreaterThan(0d); + assertThat(meterAdmittedCapacity.value()).isEqualTo((double) admitWeight); + assertThat(meterCapacity.value()) + .isEqualTo((double) config.sizing().orElseThrow().fixedSize().orElseThrow().asLong()); + + var seenAdmittedWeightExceeded = false; + var stop = new AtomicBoolean(); + try (var executor = Executors.newFixedThreadPool(numThreads)) { + for (int i = 0; i < numThreads; i++) { + executor.execute( + () -> { + while (!stop.get()) { + cache.put( + "repo", SimpleTestObj.builder().id(idGen.incrementAndGet()).text(str).build()); + Thread.yield(); + } + }); + } + + for (int i = 0; i < 50; i++) { + Thread.sleep(10); + var w = cache.currentWeightReported(); + if (w > admitWeight) { + seenAdmittedWeightExceeded = true; + } + } + + stop.set(true); + } + + // We may (with an low probability) see rejections. + // Rejections are expected, but neither their occurrence nor their non-occurrence can be in any + // way guaranteed by this test. + // This means, assertions on the number of rejections and derived values are pretty much + // impossible. + // The probabilities are directly related to the system and state of that system running the + // test. + // + // assertThat(cache.rejections()).isGreaterThan(0L); + // assertThat(meterRejectedWeight.totalAmount()).isGreaterThan(0d); + + // This must actually never fail. (Those might still though, in very rare cases.) + assertThat(cache.currentWeightReported()).isLessThanOrEqualTo(admitWeight); + assertThat(seenAdmittedWeightExceeded).isFalse(); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheSizing.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheSizing.java new file mode 100644 index 0000000000..19220a2467 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheSizing.java @@ -0,0 +1,137 @@ +/* + * 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.impl.cache; + +import static org.apache.polaris.persistence.nosql.api.cache.CacheSizing.DEFAULT_HEAP_FRACTION; + +import org.apache.polaris.misc.types.memorysize.MemorySize; +import org.apache.polaris.persistence.nosql.api.cache.CacheSizing; +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; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestCacheSizing { + static final long BYTES_1G = 1024 * 1024 * 1024; + static final long BYTES_512M = 512 * 1024 * 1024; + static final long BYTES_4G = 4L * 1024 * 1024 * 1024; + static final long BYTES_256M = 256 * 1024 * 1024; + @InjectSoftAssertions protected SoftAssertions soft; + + @Test + void illegalFractionSettings() { + soft.assertThatIllegalStateException() + .isThrownBy(() -> CacheSizing.builder().fractionOfMaxHeapSize(-.1d).build()); + soft.assertThatIllegalStateException() + .isThrownBy(() -> CacheSizing.builder().fractionOfMaxHeapSize(1.1d).build()); + } + + @Test + void illegalFixedSettings() { + soft.assertThatIllegalStateException() + .isThrownBy(() -> CacheSizing.builder().fixedSize(MemorySize.ofMega(-1)).build()); + } + + @Test + void fixedSizeWins() { + var fixedSize = MemorySize.ofMega(3); + soft.assertThat( + CacheSizing.builder() + .fixedSize(fixedSize) + .fractionOfMaxHeapSize(.5) + .build() + .calculateEffectiveSize(BYTES_512M, DEFAULT_HEAP_FRACTION)) + .isEqualTo(fixedSize.asLong()); + } + + @Test + void tinyHeap() { + // Assuming a 256MB max heap, requesting 70% (358MB), calc yields 64MB (min-size) + var fractionMinSize = MemorySize.ofMega(64); + soft.assertThat( + CacheSizing.builder() + .fractionOfMaxHeapSize(.7) + .fractionMinSize(fractionMinSize) + .build() + .calculateEffectiveSize(BYTES_256M, DEFAULT_HEAP_FRACTION)) + .isEqualTo(fractionMinSize.asLong()); + } + + @Test + void tinyHeapNoCache() { + // Assuming a 256MB max heap, requesting 70% (179MB), calc yields fractionMinSizeMb, i.e. zero + var fractionMinSize = MemorySize.ofMega(0); + soft.assertThat( + CacheSizing.builder() + .fractionOfMaxHeapSize(.7) + .fractionMinSize(fractionMinSize) + .build() + .calculateEffectiveSize(BYTES_256M, DEFAULT_HEAP_FRACTION)) + .isEqualTo(fractionMinSize.asLong()); + } + + @Test + void defaultSettings4G() { + // Assuming a 4G max heap, requesting 70% (358MB), sizing must yield 2867MB. + soft.assertThat( + CacheSizing.builder().build().calculateEffectiveSize(BYTES_4G, DEFAULT_HEAP_FRACTION)) + .isEqualTo(2576980377L); + } + + @Test + void defaultSettings1G() { + soft.assertThat( + CacheSizing.builder().build().calculateEffectiveSize(BYTES_1G, DEFAULT_HEAP_FRACTION)) + // 70 % of 1024 MB + .isEqualTo(644245094L); + } + + @Test + void defaultSettingsTiny() { + soft.assertThat( + CacheSizing.builder().build().calculateEffectiveSize(BYTES_256M, DEFAULT_HEAP_FRACTION)) + // 70 % of 1024 MB + .isEqualTo(MemorySize.ofMega(64).asLong()); + } + + @Test + void turnOff() { + soft.assertThat( + CacheSizing.builder() + .fixedSize(MemorySize.ofMega(0)) + .build() + .calculateEffectiveSize(BYTES_1G, DEFAULT_HEAP_FRACTION)) + // 70 % of 1024 MB + .isEqualTo(MemorySize.ofMega(0).asLong()); + } + + @Test + void keepsHeapFree() { + // Assuming a 512MB max heap, requesting 70% (358MB), exceeds "min free" of 256MB, sizing must + // yield 256MB. + soft.assertThat( + CacheSizing.builder() + .fractionOfMaxHeapSize(.7) + .build() + .calculateEffectiveSize(BYTES_512M, DEFAULT_HEAP_FRACTION)) + .isEqualTo(MemorySize.ofMega(256).asLong()); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCachingInMemoryPersist.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCachingInMemoryPersist.java new file mode 100644 index 0000000000..f4f5ff7b3c --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCachingInMemoryPersist.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.impl.cache; + +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import java.util.Optional; +import org.apache.polaris.ids.api.IdGenerator; +import org.apache.polaris.misc.types.memorysize.MemorySize; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.cache.CacheConfig; +import org.apache.polaris.persistence.nosql.api.cache.CacheSizing; +import org.apache.polaris.persistence.nosql.api.obj.ImmutableSimpleTestObj; +import org.apache.polaris.persistence.nosql.api.obj.SimpleTestObj; +import org.apache.polaris.persistence.nosql.impl.AbstractPersistenceTests; +import org.apache.polaris.persistence.nosql.testextension.BackendSpec; +import org.apache.polaris.persistence.nosql.testextension.PersistenceTestExtension; +import org.apache.polaris.persistence.nosql.testextension.PolarisPersistence; +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.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith({PersistenceTestExtension.class, SoftAssertionsExtension.class}) +@BackendSpec +public class TestCachingInMemoryPersist extends AbstractPersistenceTests { + @PolarisPersistence(caching = true) + protected Persistence persistence; + + @Override + protected Persistence persistence() { + return persistence; + } + + @Nested + @ExtendWith({PersistenceTestExtension.class, SoftAssertionsExtension.class}) + public class CacheSpecific { + @InjectSoftAssertions protected SoftAssertions soft; + + @PolarisPersistence(caching = true) + protected Persistence persist; + + @PolarisPersistence protected IdGenerator idGenerator; + + @Test + public void getImmediate() { + var obj = ImmutableSimpleTestObj.builder().id(idGenerator.generateId()).text("foo").build(); + soft.assertThat(persist.getImmediate(objRef(obj.withNumParts(1)), SimpleTestObj.class)) + .isNull(); + var written = persist.write(obj, SimpleTestObj.class); + soft.assertThat(persist.getImmediate(objRef(written), SimpleTestObj.class)) + .isEqualTo(written); + persist.delete(objRef(written)); + soft.assertThat(persist.getImmediate(objRef(written), SimpleTestObj.class)).isNull(); + persist.write(obj, SimpleTestObj.class); + persist.fetch(objRef(written), SimpleTestObj.class); + soft.assertThat(persist.getImmediate(objRef(written), SimpleTestObj.class)) + .isEqualTo(written); + } + + @Test + public void nonEffectiveNegativeCache(@PolarisPersistence Persistence persist) { + var backing = spy(persist); + var cacheBackend = + PersistenceCaches.newBackend( + CacheConfig.BuildableCacheConfig.builder() + .sizing(CacheSizing.builder().fixedSize(MemorySize.ofMega(16)).build()) + .build(), + Optional.empty()); + var cachedPersist = spy(cacheBackend.wrap(backing)); + + reset(backing); + + var id = objRef(SimpleTestObj.TYPE, idGenerator.generateId(), 1); + + soft.assertThat(cachedPersist.fetch(id, SimpleTestObj.class)).isNull(); + verify(cachedPersist).fetch(id, SimpleTestObj.class); + verify(backing).fetch(id, SimpleTestObj.class); + verify(backing).fetchMany(same(SimpleTestObj.class), any()); + // BasePersistence calls 'doFetch()', which is protected and not accessible from this test + // verifyNoMoreInteractions(backing); + verifyNoMoreInteractions(cachedPersist); + reset(backing, cachedPersist); + + // repeat + soft.assertThat(cachedPersist.fetch(id, SimpleTestObj.class)).isNull(); + verify(cachedPersist).fetch(id, SimpleTestObj.class); + verify(backing).fetch(id, SimpleTestObj.class); + verify(backing).fetchMany(same(SimpleTestObj.class), any()); + // BasePersistence calls 'doFetch()', which is protected and not accessible from this test + // verifyNoMoreInteractions(backing); + verifyNoMoreInteractions(cachedPersist); + reset(backing, cachedPersist); + } + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestDistributedInvalidations.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestDistributedInvalidations.java new file mode 100644 index 0000000000..aa9f3ccdec --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestDistributedInvalidations.java @@ -0,0 +1,281 @@ +/* + * 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.impl.cache; + +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import jakarta.annotation.Nonnull; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.polaris.misc.types.memorysize.MemorySize; +import org.apache.polaris.persistence.nosql.api.cache.CacheBackend; +import org.apache.polaris.persistence.nosql.api.cache.CacheConfig; +import org.apache.polaris.persistence.nosql.api.cache.CacheSizing; +import org.apache.polaris.persistence.nosql.api.cache.DistributedCacheInvalidation; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.SimpleTestObj; +import org.apache.polaris.persistence.nosql.api.obj.VersionedTestObj; +import org.apache.polaris.persistence.nosql.api.ref.ImmutableReference; +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.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestDistributedInvalidations { + @InjectSoftAssertions protected SoftAssertions soft; + + protected AtomicLong clockNanos; + + CaffeineCacheBackend backend1noSpy; + CaffeineCacheBackend backend2noSpy; + CaffeineCacheBackend backend1; + CaffeineCacheBackend backend2; + + protected CacheBackend distributed1; + protected CacheBackend distributed2; + + protected DistributedCacheInvalidation.Sender sender1; + protected DistributedCacheInvalidation.Sender sender2; + + protected String realmId; + + @BeforeEach + public void setup() { + realmId = "42"; + + clockNanos = new AtomicLong(); + + backend1noSpy = + (CaffeineCacheBackend) + PersistenceCaches.newBackend( + CacheConfig.BuildableCacheConfig.builder() + .sizing(CacheSizing.builder().fixedSize(MemorySize.ofMega(16)).build()) + .referenceTtl(Duration.ofMinutes(1)) + .referenceNegativeTtl(Duration.ofSeconds(1)) + .clockNanos(clockNanos::get) + .build(), + Optional.empty()); + backend2noSpy = + (CaffeineCacheBackend) + PersistenceCaches.newBackend( + CacheConfig.BuildableCacheConfig.builder() + .sizing(CacheSizing.builder().fixedSize(MemorySize.ofMega(16)).build()) + .referenceTtl(Duration.ofMinutes(1)) + .referenceNegativeTtl(Duration.ofSeconds(1)) + .clockNanos(clockNanos::get) + .build(), + Optional.empty()); + + backend1 = spy(backend1noSpy); + backend2 = spy(backend2noSpy); + + // reversed! + sender1 = spy(delegate(backend2)); + sender2 = spy(delegate(backend1)); + + distributed1 = new DistributedInvalidationsCacheBackend(backend1, sender1); + distributed2 = new DistributedInvalidationsCacheBackend(backend2, sender2); + } + + @Test + public void obj() { + var obj1 = VersionedTestObj.builder().id(100).versionToken("1").someValue("hello").build(); + var obj2 = VersionedTestObj.builder().id(100).versionToken("2").someValue("again").build(); + + distributed1.put(realmId, obj1); + + verify(backend1).cachePut(any(), any()); + verify(backend1).putLocal(realmId, obj1); + verify(backend2).remove(realmId, objRef(obj1)); + verify(sender1).evictObj(realmId, objRef(obj1)); + verifyNoMoreInteractions(backend1); + verifyNoMoreInteractions(backend2); + verifyNoMoreInteractions(sender1); + verifyNoMoreInteractions(sender2); + resetAll(); + + soft.assertThat(backend1noSpy.get(realmId, objRef(obj1))).isEqualTo(obj1); + soft.assertThat(backend2noSpy.get(realmId, objRef(obj1))).isNull(); + + // Simulate that backend2 loaded obj1 in the meantime + backend2noSpy.put(realmId, obj1); + soft.assertThat(backend2noSpy.get(realmId, objRef(obj1))).isEqualTo(obj1); + + distributed1.put(realmId, obj2); + soft.assertThat(backend2noSpy.get(realmId, objRef(obj1))).isNull(); + + verify(backend1).cachePut(any(), any()); + verify(backend1).putLocal(realmId, obj2); + verify(backend2).remove(realmId, objRef(obj1)); + verify(sender1).evictObj(realmId, objRef(obj2)); + verifyNoMoreInteractions(backend1); + verifyNoMoreInteractions(backend2); + verifyNoMoreInteractions(sender1); + verifyNoMoreInteractions(sender2); + resetAll(); + + // Simulate that backend2 loaded obj2 in the meantime + backend2noSpy.put(realmId, obj2); + soft.assertThat(backend2noSpy.get(realmId, objRef(obj2))).isEqualTo(obj2); + + // update to same object (still a removal for backend2) + + distributed1.put(realmId, obj2); + + verify(backend1).cachePut(any(), any()); + verify(backend1).putLocal(realmId, obj2); + verify(backend2).remove(realmId, objRef(obj2)); + verify(sender1).evictObj(realmId, objRef(obj2)); + verifyNoMoreInteractions(backend1); + verifyNoMoreInteractions(backend2); + verifyNoMoreInteractions(sender1); + verifyNoMoreInteractions(sender2); + resetAll(); + + // Verify that ref2 has not been removed (same hash) + soft.assertThat(backend2noSpy.get(realmId, objRef(obj2))).isNull(); + + // remove object + + distributed1.remove(realmId, objRef(obj2)); + + verify(backend1).remove(realmId, objRef(obj2)); + verify(backend2).remove(realmId, objRef(obj2)); + verify(sender1).evictObj(realmId, objRef(obj2)); + verifyNoMoreInteractions(backend1); + verifyNoMoreInteractions(backend2); + verifyNoMoreInteractions(sender1); + verifyNoMoreInteractions(sender2); + resetAll(); + } + + @Test + public void reference() { + var ref1 = + ImmutableReference.builder() + .name("refs/foo/bar") + .pointer(objRef(SimpleTestObj.TYPE, 100, 1)) + .createdAtMicros(0) + .previousPointers() + .build(); + var ref2 = + ImmutableReference.builder() + .from(ref1) + .pointer(objRef(SimpleTestObj.TYPE, 101, 1)) + .previousPointers() + .build(); + + distributed1.putReference(realmId, ref1); + + verify(backend1).cachePut(any(), any()); + verify(backend1).putReferenceLocal(realmId, ref1); + verify(backend2).removeReference(realmId, ref1.name()); + verify(sender1).evictReference(realmId, ref1.name()); + verifyNoMoreInteractions(backend1); + verifyNoMoreInteractions(backend2); + verifyNoMoreInteractions(sender1); + verifyNoMoreInteractions(sender2); + resetAll(); + + soft.assertThat(backend1noSpy.getReference(realmId, ref1.name())).isEqualTo(ref1); + soft.assertThat(backend2noSpy.getReference(realmId, ref1.name())).isNull(); + + // Simulate that backend2 loaded ref1 in the meantime + backend2noSpy.putReference(realmId, ref1); + soft.assertThat(backend2noSpy.getReference(realmId, ref1.name())).isEqualTo(ref1); + + distributed1.putReference(realmId, ref2); + soft.assertThat(backend2noSpy.getReference(realmId, ref1.name())).isNull(); + + verify(backend1).cachePut(any(), any()); + verify(backend1).putReferenceLocal(realmId, ref2); + verify(backend2).removeReference(realmId, ref1.name()); + verify(backend2).removeReference(realmId, ref1.name()); + verify(sender1).evictReference(realmId, ref2.name()); + verifyNoMoreInteractions(backend1); + verifyNoMoreInteractions(backend2); + verifyNoMoreInteractions(sender1); + verifyNoMoreInteractions(sender2); + resetAll(); + + // Simulate that backend2 loaded ref2 in the meantime + backend2noSpy.putReference(realmId, ref2); + soft.assertThat(backend2noSpy.getReference(realmId, ref2.name())).isEqualTo(ref2); + + // update to same reference (no change for backend2) + + distributed1.putReference(realmId, ref2); + + verify(backend1).cachePut(any(), any()); + verify(backend1).putReferenceLocal(realmId, ref2); + verify(backend2).removeReference(realmId, ref2.name()); + verify(sender1).evictReference(realmId, ref2.name()); + verifyNoMoreInteractions(backend1); + verifyNoMoreInteractions(backend2); + verifyNoMoreInteractions(sender1); + verifyNoMoreInteractions(sender2); + resetAll(); + + // Verify that ref2 has been removed in backend2 + soft.assertThat(backend2noSpy.getReference(realmId, ref2.name())).isNull(); + + // remove reference + + distributed1.removeReference(realmId, ref2.name()); + + verify(backend1).removeReference(realmId, ref2.name()); + verify(backend2).removeReference(realmId, ref2.name()); + verify(sender1).evictReference(realmId, ref2.name()); + verifyNoMoreInteractions(backend1); + verifyNoMoreInteractions(backend2); + verifyNoMoreInteractions(sender1); + verifyNoMoreInteractions(sender2); + resetAll(); + } + + private void resetAll() { + reset(backend1); + reset(backend2); + reset(sender1); + reset(sender2); + } + + protected static DistributedCacheInvalidation.Sender delegate(CacheBackend backend) { + return new DistributedCacheInvalidation.Sender() { + @Override + public void evictObj(@Nonnull String realmId, @Nonnull ObjRef objRef) { + backend.remove(realmId, objRef); + } + + @Override + public void evictReference(@Nonnull String realmId, @Nonnull String refName) { + backend.removeReference(realmId, refName); + } + }; + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestReferenceCaching.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestReferenceCaching.java new file mode 100644 index 0000000000..40ab8ea0b1 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestReferenceCaching.java @@ -0,0 +1,193 @@ +/* + * 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.impl.cache; + +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; +import org.apache.polaris.ids.api.IdGenerator; +import org.apache.polaris.misc.types.memorysize.MemorySize; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.cache.CacheConfig; +import org.apache.polaris.persistence.nosql.api.cache.CacheSizing; +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.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.SimpleTestObj; +import org.apache.polaris.persistence.nosql.api.ref.Reference; +import org.apache.polaris.persistence.nosql.testextension.BackendSpec; +import org.apache.polaris.persistence.nosql.testextension.PersistenceTestExtension; +import org.apache.polaris.persistence.nosql.testextension.PolarisPersistence; +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.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith({PersistenceTestExtension.class, SoftAssertionsExtension.class}) +@BackendSpec +public class TestReferenceCaching { + @InjectSoftAssertions protected SoftAssertions soft; + + Persistence wrapWithCache(Persistence persist, LongSupplier clockNanos) { + return PersistenceCaches.newBackend( + CacheConfig.BuildableCacheConfig.builder() + .sizing(CacheSizing.builder().fixedSize(MemorySize.ofMega(16)).build()) + .clockNanos(clockNanos) + .referenceTtl(Duration.ofMinutes(1)) + .referenceNegativeTtl(Duration.ofSeconds(1)) + .build(), + Optional.empty()) + .wrap(persist); + } + + // Two caching `Persist` instances, using _independent_ cache backends. + Persistence withCache1; + Persistence withCache2; + + AtomicLong nowNanos; + + @PolarisPersistence IdGenerator idGenerator; + + @BeforeEach + void wrapCaches( + @PolarisPersistence(realmId = "2") Persistence persist1, + @PolarisPersistence(realmId = "2") Persistence persist2) { + nowNanos = new AtomicLong(); + withCache1 = wrapWithCache(persist1, nowNanos::get); + withCache2 = wrapWithCache(persist2, nowNanos::get); + } + + ObjRef newId() { + return objRef(SimpleTestObj.TYPE, idGenerator.generateId(), 1); + } + + /** Explicit cache-expiry via {@link Persistence#fetchReferenceForUpdate(String)}. */ + @Test + public void referenceCacheInconsistency(TestInfo testInfo) { + var refName = testInfo.getTestMethod().orElseThrow().getName(); + + // Create ref via instance 1 + var ref = withCache1.createReference(refName, Optional.of(newId())); + + // Populate cache in instance 2 + soft.assertThat(fetchRef(withCache2, ref.name())).isEqualTo(ref); + + // Update ref via instance 1 + var refUpdated = + withCache1.updateReferencePointer(ref, objRef(SimpleTestObj.TYPE, 101, 1)).orElseThrow(); + soft.assertThat(refUpdated).isNotEqualTo(ref); + + soft.assertThat(fetchRef(withCache1, ref.name())).isEqualTo(refUpdated); + // Other test instance did NOT update its cache + soft.assertThat(fetchRef(withCache2, ref.name())) + .extracting(Reference::pointer) + .describedAs("Previous: %s, updated: %s", ref.pointer(), refUpdated.pointer()) + .isEqualTo(ref.pointer()) + .isNotEqualTo(refUpdated.pointer()); + + soft.assertThat(withCache2.fetchReferenceForUpdate(ref.name())).isEqualTo(refUpdated); + } + + /** Reference cache TTL expiry. */ + @Test + public void referenceCacheExpiry(TestInfo testInfo) { + var refName = testInfo.getTestMethod().orElseThrow().getName(); + + // Create ref via instance 1 + var ref = withCache1.createReference(refName, Optional.of(newId())); + + // Populate cache in instance 2 + soft.assertThat(fetchRef(withCache2, ref.name())).isEqualTo(ref); + + // Update ref via instance 1 + var refUpdated = withCache1.updateReferencePointer(ref, newId()).orElseThrow(); + soft.assertThat(refUpdated).isNotEqualTo(ref); + + soft.assertThat(fetchRef(withCache1, ref.name())).isEqualTo(refUpdated); + // Other test instance did NOT update its cache + soft.assertThat(fetchRef(withCache2, ref.name())) + .extracting(Reference::pointer) + .describedAs("Previous: %s, updated: %s", ref.pointer(), refUpdated.pointer()) + .isEqualTo(ref.pointer()) + .isNotEqualTo(refUpdated.pointer()); + + // + + nowNanos.addAndGet(Duration.ofMinutes(2).toNanos()); + soft.assertThat(fetchRef(withCache2, ref.name())).isEqualTo(refUpdated); + } + + /** Tests negative-cache behavior (non-existence of a reference). */ + @Test + public void referenceCacheNegativeExpiry(TestInfo testInfo) { + var refName = testInfo.getTestMethod().orElseThrow().getName(); + + // Populate both caches w/ negative entries + soft.assertThatThrownBy(() -> fetchRef(withCache1, refName)) + .isInstanceOf(ReferenceNotFoundException.class); + soft.assertThatThrownBy(() -> fetchRef(withCache2, refName)) + .isInstanceOf(ReferenceNotFoundException.class); + + // Create ref via instance 1 + var ref = withCache1.createReference(refName, Optional.of(newId())); + + // Cache 1 has "correct" entry + soft.assertThat(fetchRef(withCache1, ref.name())).isEqualTo(ref); + // Cache 2 has stale negative entry + soft.assertThatThrownBy(() -> fetchRef(withCache2, refName)) + .isInstanceOf(ReferenceNotFoundException.class); + + // Expire negative cache entries + nowNanos.addAndGet(Duration.ofSeconds(2).toNanos()); + soft.assertThat(fetchRef(withCache2, ref.name())).isEqualTo(ref); + } + + @Test + public void addReference(TestInfo testInfo) { + var refName = testInfo.getTestMethod().orElseThrow().getName(); + + // Create ref via instance 1 + var ref = withCache1.createReference(refName, Optional.of(newId())); + + // Try addReference via instance 2 + soft.assertThatThrownBy(() -> withCache2.createReference(refName, Optional.of(newId()))) + .isInstanceOf(ReferenceAlreadyExistsException.class); + + // Update ref via instance 1 + var refUpdated = withCache1.updateReferencePointer(ref, newId()).orElseThrow(); + soft.assertThat(refUpdated).isNotEqualTo(ref); + + soft.assertThat(fetchRef(withCache1, ref.name())).isEqualTo(refUpdated); + // Other test instance DID populate its cache + soft.assertThat(fetchRef(withCache2, ref.name())) + .extracting(Reference::pointer) + .describedAs("Previous: %s, updated: %s", ref.pointer(), refUpdated.pointer()) + .isEqualTo(refUpdated.pointer()); + } + + static Reference fetchRef(Persistence persist, String refName) { + return persist.fetchReference(refName); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/TestCommitLogImpl.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/TestCommitLogImpl.java new file mode 100644 index 0000000000..a212a47f80 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/TestCommitLogImpl.java @@ -0,0 +1,24 @@ +/* + * 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.impl.commits; + +import org.apache.polaris.persistence.nosql.testextension.BackendSpec; + +@BackendSpec +public class TestCommitLogImpl extends BaseTestCommitLogImpl {} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/TestCommitterImpl.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/TestCommitterImpl.java new file mode 100644 index 0000000000..0776fb22f4 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/TestCommitterImpl.java @@ -0,0 +1,24 @@ +/* + * 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.impl.commits; + +import org.apache.polaris.persistence.nosql.testextension.BackendSpec; + +@BackendSpec +public class TestCommitterImpl extends BaseTestCommitterImpl {} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/retry/TestRetryLoopConcurrency.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/retry/TestRetryLoopConcurrency.java new file mode 100644 index 0000000000..9e985a8001 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/retry/TestRetryLoopConcurrency.java @@ -0,0 +1,146 @@ +/* + * 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.impl.commits.retry; + +import java.util.ArrayList; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.polaris.ids.api.MonotonicClock; +import org.apache.polaris.ids.impl.MonotonicClockImpl; +import org.apache.polaris.persistence.nosql.api.commit.RetryConfig; +import org.apache.polaris.persistence.nosql.api.commit.RetryTimeoutException; +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.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +@Disabled("Long running test disabled") +public class TestRetryLoopConcurrency { + @InjectSoftAssertions SoftAssertions soft; + + MonotonicClock clock; + RetryConfig retryConfig; + + @BeforeEach + void setUp() { + clock = MonotonicClockImpl.newDefaultInstance(); + retryConfig = RetryConfig.BuildableRetryConfig.builder().build(); + } + + @AfterEach + void tearDown() { + clock.close(); + } + + @Test + public void retryLoopConcurrencyRetryNoTimeout() throws Exception { + var value = new AtomicInteger(); + var threads = 8; + var stop = new AtomicBoolean(); + var timeouts = new AtomicInteger(); + var successes = new AtomicInteger(); + + var startLatch = new CountDownLatch(threads); + var runLatch = new CountDownLatch(1); + var doneLatch = new CountDownLatch(threads); + + var totalRetries = new AtomicInteger(); + var totalSleepTime = new AtomicLong(); + + var futures = new ArrayList>(); + + try (var executor = Executors.newFixedThreadPool(threads)) { + for (int i = 0; i < threads; i++) { + futures.add( + executor.submit( + () -> { + try { + startLatch.countDown(); + try { + runLatch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + while (!stop.get()) { + try { + RetryLoop.newRetryLoop(retryConfig, clock) + .setRetryStatsConsumer( + ((result, retries, sleepTimeMillis, totalDurationNanos) -> { + totalRetries.addAndGet(retries); + totalSleepTime.addAndGet(sleepTimeMillis); + })) + .retryLoop( + (long nanosRemaining) -> { + int v = value.get(); + // Let other thread(s) continue to cause CAS failures. + Thread.yield(); + return value.compareAndSet(v, v + 1) + ? Optional.of(v) + : Optional.empty(); + }); + successes.incrementAndGet(); + } catch (RetryTimeoutException timeoutException) { + timeouts.incrementAndGet(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } finally { + doneLatch.countDown(); + } + })); + } + + startLatch.await(); + runLatch.countDown(); + + Thread.sleep(60_000); + stop.set(true); + + doneLatch.await(); + + System.err.printf( + """ + Successes: %d + Timeouts: %d + Retries: %d + SleepTime: %d + + """, + successes.get(), timeouts.get(), totalRetries.get(), totalSleepTime.get()); + + soft.assertThat(timeouts).hasValue(0); + soft.assertThat(successes).hasValueGreaterThan(0); + + for (Future f : futures) { + soft.assertThatCode(f::get).doesNotThrowAnyException(); + } + } + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/retry/TestRetryLoopImpl.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/retry/TestRetryLoopImpl.java new file mode 100644 index 0000000000..c168675387 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/retry/TestRetryLoopImpl.java @@ -0,0 +1,362 @@ +/* + * 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.impl.commits.retry; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.longThat; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.polaris.ids.api.MonotonicClock; +import org.apache.polaris.persistence.nosql.api.commit.FairRetriesType; +import org.apache.polaris.persistence.nosql.api.commit.RetryConfig; +import org.apache.polaris.persistence.nosql.api.commit.RetryTimeoutException; +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.ValueSource; +import org.mockito.ArgumentMatcher; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestRetryLoopImpl { + @InjectSoftAssertions SoftAssertions soft; + + @Test + public void retryTimeout() { + var retries = 3; + var mockedConfig = mockedConfig(retries, Integer.MAX_VALUE); + + var clock = mockedClock(retries); + var tryLoopState = new RetryLoopImpl<>(mockedConfig, clock); + + var retryCounter = new AtomicInteger(); + + soft.assertThatThrownBy( + () -> + tryLoopState.retryLoop( + (long nanosRemaining) -> { + retryCounter.incrementAndGet(); + return Optional.empty(); + })) + .isInstanceOf(RetryTimeoutException.class) + .asInstanceOf(type(RetryTimeoutException.class)) + .extracting(RetryTimeoutException::getRetry, RetryTimeoutException::getTimeNanos) + .containsExactly(3, 0L); + soft.assertThat(retryCounter).hasValue(1 + retries); + } + + @Test + public void retryImmediateSuccess() { + var retries = 3; + var mockedConfig = mockedConfig(retries, Integer.MAX_VALUE); + + var clock = mockedClock(retries); + var tryLoopState = new RetryLoopImpl(mockedConfig, clock); + + var retryCounter = new AtomicInteger(); + var result = new AtomicReference(); + + soft.assertThatCode( + () -> + result.set( + tryLoopState.retryLoop( + (long nanosRemaining) -> { + retryCounter.incrementAndGet(); + return Optional.of("foo"); + }))) + .doesNotThrowAnyException(); + + soft.assertThat(retryCounter).hasValue(1); + soft.assertThat(result).hasValue("foo"); + } + + @Test + public void retry() { + var retries = 3; + var mockedConfig = mockedConfig(retries, Integer.MAX_VALUE); + + var clock = mockedClock(retries); + var tryLoopState = new RetryLoopImpl(mockedConfig, clock); + + var retryCounter = new AtomicInteger(); + var result = new AtomicReference(); + + soft.assertThatCode( + () -> + result.set( + tryLoopState.retryLoop( + (long nanosRemaining) -> { + if (retryCounter.incrementAndGet() == 1) { + return Optional.empty(); + } + return Optional.of("foo"); + }))) + .doesNotThrowAnyException(); + + soft.assertThat(retryCounter).hasValue(2); + soft.assertThat(result).hasValue("foo"); + } + + @Test + public void retryUnmocked() { + var mockedConfig = mockedConfig(3, Integer.MAX_VALUE, 1, 1000, 1); + + var clock = mockedClock(3); + var tryLoopState = new RetryLoopImpl(mockedConfig, clock); + + var retryCounter = new AtomicInteger(); + var result = new AtomicReference(); + + soft.assertThatCode( + () -> + result.set( + tryLoopState.retryLoop( + (long nanosRemaining) -> { + if (retryCounter.incrementAndGet() == 1) { + return Optional.empty(); + } + return Optional.of("foo"); + }))) + .doesNotThrowAnyException(); + + soft.assertThat(retryCounter).hasValue(2); + soft.assertThat(result).hasValue("foo"); + } + + @Test + public void sleepConsidersAttemptDuration() { + var mockedConfig = + mockedConfig(Integer.MAX_VALUE, Integer.MAX_VALUE, 100, 100, Integer.MAX_VALUE); + + var clock = mockedClock(3); + var tryLoopState = new RetryLoopImpl<>(mockedConfig, clock); + var t0 = clock.nanoTime(); + + soft.assertThat(tryLoopState.canRetry(t0, MILLISECONDS.toNanos(20))).isTrue(); + verify(clock, times(1)).sleepMillis(80L); + + // bounds doubled + + soft.assertThat(tryLoopState.canRetry(t0, MILLISECONDS.toNanos(30))).isTrue(); + verify(clock, times(1)).sleepMillis(170L); + } + + @ParameterizedTest + @ValueSource(longs = {1, 5, 50, 100, 200}) + public void doesNotSleepLongerThanMax(long maxSleep) { + var retries = 50; + var clock = mockedClock(retries); + + var initialLower = 1L; + var initialUpper = 2L; + + var lower = initialLower; + var upper = initialUpper; + var tryLoopState = + new RetryLoopImpl<>(mockedConfig(retries, Integer.MAX_VALUE, 1, upper, maxSleep), clock); + var t0 = clock.nanoTime(); + + verify(clock, times(1)).nanoTime(); + + var inOrderClock = inOrder(clock); + + for (int i = 0; i < retries; i++) { + soft.assertThat(tryLoopState.canRetry(t0, 0L)).isTrue(); + long finalLower = lower; + long finalUpper = upper; + ArgumentMatcher matcher = + new ArgumentMatcher<>() { + @Override + public boolean matches(Long l) { + return l >= finalLower && l <= finalUpper && l <= maxSleep; + } + + @Override + public String toString() { + return "lower = " + finalLower + ", upper = " + finalUpper + ", max = " + maxSleep; + } + }; + inOrderClock.verify(clock, times(1)).sleepMillis(longThat(matcher)); + + if (upper * 2 <= maxSleep) { + lower *= 2; + upper *= 2; + } else { + upper = maxSleep; + } + } + + verify(clock, times(1 + retries)).nanoTime(); + } + + @ParameterizedTest + @ValueSource(ints = {1, 5, 50}) + public void retriesWithinBounds(int retries) { + var clock = mockedClock(retries); + + var tryLoopState = new RetryLoopImpl<>(mockedConfig(retries, 42L), clock); + var t0 = clock.nanoTime(); + + verify(clock, times(1)).nanoTime(); + + for (var i = 0; i < retries; i++) { + soft.assertThat(tryLoopState.canRetry(t0, 0L)).isTrue(); + } + + verify(clock, times(1 + retries)).nanoTime(); + verify(clock, times(retries)).sleepMillis(anyLong()); + } + + @Test + public void retryUnsuccessful() { + var retries = 3; + + var clock = mockedClock(retries); + + var tryLoopState = new RetryLoopImpl<>(mockedConfig(retries, 42L), clock); + var t0 = clock.nanoTime(); + + for (var i = 0; i < retries; i++) { + soft.assertThat(tryLoopState.canRetry(t0, 0L)).isTrue(); + } + + soft.assertThat(tryLoopState.canRetry(t0, 0L)).isFalse(); + } + + @ParameterizedTest + @ValueSource(ints = {1, 5, 50}) + public void retriesOutOfBounds(int retries) { + var clock = mockedClock(retries); + + var tryLoopState = new RetryLoopImpl<>(mockedConfig(retries - 1, 42L), clock); + var t0 = clock.nanoTime(); + + verify(clock, times(1)).nanoTime(); + + for (var i = 0; i < retries - 1; i++) { + soft.assertThat(tryLoopState.canRetry(t0, 0L)).isTrue(); + } + + verify(clock, times(retries)).nanoTime(); + verify(clock, times(retries - 1)).sleepMillis(anyLong()); + + soft.assertThat(tryLoopState.canRetry(t0, 0L)).isFalse(); + } + + @Test + public void sleepDurations() { + var retries = 10; + + var clock = mockedClock(retries); + + // Must be "big" enough so that the upper/lower sleep-time-bounds doubling exceed this value + var timeoutMillis = 42L; + + var config = mockedConfig(retries, timeoutMillis); + var tryLoopState = new RetryLoopImpl<>(config, clock); + var t0 = clock.nanoTime(); + + var lower = config.initialSleepLower().toMillis(); + var upper = config.initialSleepUpper().toMillis(); + + for (var i = 0; i < retries; i++) { + soft.assertThat(tryLoopState.canRetry(t0, 0L)).isTrue(); + + long l = Math.min(lower, timeoutMillis); + long u = Math.min(upper, timeoutMillis); + + verify(clock).sleepMillis(longThat(v -> v >= l && v <= u)); + clearInvocations(clock); + + lower *= 2; + upper *= 2; + } + } + + @ParameterizedTest + @ValueSource(ints = {1, 5, 50}) + public void retriesOutOfTime(int retries) { + var times = new Long[retries]; + Arrays.fill(times, 0L); + times[retries - 1] = MILLISECONDS.toNanos(43L); + var clock = mockedClock(0L, times); + + var tryLoopState = new RetryLoopImpl<>(mockedConfig(retries, 42L), clock); + var t0 = clock.nanoTime(); + + verify(clock, times(1)).nanoTime(); + + for (var i = 0; i < retries - 1; i++) { + soft.assertThat(tryLoopState.canRetry(t0, 0L)).isTrue(); + } + + verify(clock, times(retries)).nanoTime(); + verify(clock, times(retries - 1)).sleepMillis(anyLong()); + + soft.assertThat(tryLoopState.canRetry(t0, 0L)).isFalse(); + + // Trigger the `if (unsuccessful)` case in TryLoopState.retry + soft.assertThat(tryLoopState.canRetry(t0, 0L)).isFalse(); + } + + MonotonicClock mockedClock(int retries) { + var times = new Long[retries]; + Arrays.fill(times, 0L); + return mockedClock(0L, times); + } + + MonotonicClock mockedClock(Long t0, Long... times) { + var mock = spy(MonotonicClock.class); + when(mock.nanoTime()).thenReturn(t0, times); + doNothing().when(mock).sleepMillis(anyLong()); + return mock; + } + + RetryConfig mockedConfig(int retries, long commitTimeout) { + return mockedConfig(retries, commitTimeout, 5L, 25L, Integer.MAX_VALUE); + } + + RetryConfig mockedConfig( + int commitRetries, long commitTimeout, long lowerDefault, long upperDefault, long maxSleep) { + var mock = mock(RetryConfig.class); + when(mock.retries()).thenReturn(commitRetries); + when(mock.timeout()).thenReturn(Duration.ofMillis(commitTimeout)); + when(mock.initialSleepLower()).thenReturn(Duration.ofMillis(lowerDefault)); + when(mock.initialSleepUpper()).thenReturn(Duration.ofMillis(upperDefault)); + when(mock.maxSleep()).thenReturn(Duration.ofMillis(maxSleep)); + when(mock.fairRetries()).thenReturn(FairRetriesType.UNFAIR); + return mock; + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/ObjTestValue.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/ObjTestValue.java new file mode 100644 index 0000000000..73f193dc68 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/ObjTestValue.java @@ -0,0 +1,104 @@ +/* + * 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.impl.indexes; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; +import org.apache.polaris.persistence.varint.VarInt; + +final class ObjTestValue { + private final byte[] bytes; + + public ObjTestValue(String idHex) { + this.bytes = HexFormat.of().parseHex(idHex); + } + + static ObjTestValue objTestValueFromString(String idHex) { + return new ObjTestValue(idHex); + } + + static ObjTestValue objTestValueOfSize(int size) { + return new ObjTestValue( + IntStream.range(0, size).mapToObj(i -> "10").collect(Collectors.joining())); + } + + @Override + public String toString() { + return HexFormat.of().formatHex(bytes); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + + ObjTestValue objTestValue = (ObjTestValue) o; + return Arrays.equals(bytes, objTestValue.bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + + static final IndexValueSerializer OBJ_TEST_SERIALIZER = + new IndexValueSerializer<>() { + @Override + public void skip(@Nonnull ByteBuffer buffer) { + var len = VarInt.readVarInt(buffer); + if (len > 0) { + buffer.position(buffer.position() + len); + } + } + + @Override + @Nullable + public ObjTestValue deserialize(@Nonnull ByteBuffer buffer) { + var len = VarInt.readVarInt(buffer); + if (len == 0) { + return null; + } + var bytes = new byte[len]; + buffer.get(bytes); + return new ObjTestValue(HexFormat.of().formatHex(bytes)); + } + + @Override + @Nonnull + public ByteBuffer serialize(@Nullable ObjTestValue value, @Nonnull ByteBuffer target) { + if (value == null) { + return target.put((byte) 0); + } + return VarInt.putVarInt(target, value.bytes.length).put(value.bytes); + } + + @Override + public int serializedSize(@Nullable ObjTestValue value) { + if (value == null) { + return 1; + } + return VarInt.varIntLen(value.bytes.length) + value.bytes.length; + } + }; +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestAbstractLayeredIndexImpl.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestAbstractLayeredIndexImpl.java new file mode 100644 index 0000000000..b5808198e5 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestAbstractLayeredIndexImpl.java @@ -0,0 +1,237 @@ +/* + * 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.impl.indexes; + +import static java.util.Objects.requireNonNull; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.key; +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.impl.indexes.IndexesInternal.deserializeStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.layeredIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.lazyStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.newStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.KeyIndexTestSet.basicIndexTestSet; +import static org.apache.polaris.persistence.nosql.impl.indexes.Util.randomObjId; +import static org.assertj.core.util.Lists.newArrayList; + +import java.util.ArrayList; +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.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestAbstractLayeredIndexImpl { + @InjectSoftAssertions SoftAssertions soft; + + @Test + public void isModifiedReflected() { + var reference = basicIndexTestSet().keyIndex(); + soft.assertThat(reference.isModified()).isFalse(); + + var updates = newStoreIndex(OBJ_REF_SERIALIZER); + for (var c = 'a'; c <= 'z'; c++) { + updates.add(indexElement(key(c + "foo"), randomObjId())); + } + var layered = layeredIndex(reference, updates); + soft.assertThat(updates.isModified()).isTrue(); + soft.assertThat(layered.isModified()).isTrue(); + + updates = deserializeStoreIndex(updates.serialize(), OBJ_REF_SERIALIZER); + layered = layeredIndex(reference, updates); + soft.assertThat(updates.isModified()).isFalse(); + soft.assertThat(layered.isModified()).isFalse(); + + reference.add(indexElement(key("foobar"), randomObjId())); + soft.assertThat(reference.isModified()).isTrue(); + soft.assertThat(updates.isModified()).isFalse(); + soft.assertThat(layered.isModified()).isTrue(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void isLoadedReflected(boolean updateReference) { + var reference = basicIndexTestSet().keyIndex(); + var lazyReference = lazyStoreIndex(() -> reference, null, null); + soft.assertThat(lazyReference.isLoaded()).isFalse(); + + var updates = newStoreIndex(OBJ_REF_SERIALIZER); + var lazyUpdates = lazyStoreIndex(() -> updates, null, null); + soft.assertThat(lazyUpdates.isLoaded()).isFalse(); + + var layered = layeredIndex(lazyReference, lazyUpdates); + soft.assertThat(layered.isLoaded()).isFalse(); + + if (updateReference) { + lazyReference.add(indexElement(key("abc"), randomObjId())); + soft.assertThat(lazyReference.isLoaded()).isTrue(); + soft.assertThat(lazyUpdates.isLoaded()).isFalse(); + } else { + lazyUpdates.add(indexElement(key("abc"), randomObjId())); + soft.assertThat(lazyReference.isLoaded()).isFalse(); + soft.assertThat(lazyUpdates.isLoaded()).isTrue(); + } + } + + @Test + public void basicLayered() { + var indexTestSet = basicIndexTestSet(); + var reference = indexTestSet.keyIndex(); + for (var k : indexTestSet.keys()) { + var el = requireNonNull(reference.getElement(k)).getValue(); + reference.put(k, objRef(el.type(), el.id(), 1)); + } + + var expected = new ArrayList>(); + var embedded = newStoreIndex(OBJ_REF_SERIALIZER); + reference + .elementIterator() + .forEachRemaining( + el -> { + if ((expected.size() % 5) == 0) { + el = + indexElement(el.getKey(), objRef(el.getValue().type(), ~el.getValue().id(), 1)); + embedded.add(el); + } + expected.add(el); + }); + + var layered = layeredIndex(reference, embedded); + + soft.assertThat(newArrayList(layered.elementIterator())).containsExactlyElementsOf(expected); + soft.assertThat(newArrayList(layered.reverseElementIterator())) + .containsExactlyElementsOf(expected.reversed()); + soft.assertThat(layered.asKeyList()).containsExactlyElementsOf(reference.asKeyList()); + + soft.assertThat(layered.stripes()).containsExactly(layered); + + var referenceFirst = reference.first(); + var referenceLast = reference.last(); + soft.assertThat(referenceFirst).isNotNull(); + soft.assertThat(referenceLast).isNotNull(); + soft.assertThat(layered.first()).isEqualTo(referenceFirst); + soft.assertThat(layered.last()).isEqualTo(referenceLast); + + for (var i = 0; i < expected.size(); i++) { + var el = expected.get(i); + + soft.assertThat(layered.containsElement(el.getKey())).isTrue(); + soft.assertThat(layered.getElement(el.getKey())).isEqualTo(el); + soft.assertThat(newArrayList(layered.elementIterator(el.getKey(), el.getKey(), false))) + .allMatch(elem -> elem.getKey().startsWith(el.getKey())); + + soft.assertThat(newArrayList(layered.elementIterator(el.getKey(), null, false))) + .containsExactlyElementsOf(expected.subList(i, expected.size())); + soft.assertThat(newArrayList(layered.elementIterator(null, el.getKey(), false))) + .containsExactlyElementsOf(expected.subList(0, i + 1)); + + soft.assertThat(newArrayList(layered.reverseElementIterator(el.getKey(), null, false))) + .containsExactlyElementsOf(expected.subList(i, expected.size()).reversed()); + soft.assertThat(newArrayList(layered.reverseElementIterator(null, el.getKey(), false))) + .containsExactlyElementsOf(expected.subList(0, i + 1).reversed()); + } + + var veryFirst = indexElement(key("aaaaaaaaaa"), randomObjId()); + embedded.add(veryFirst); + + soft.assertThat(layered.containsElement(veryFirst.getKey())).isTrue(); + soft.assertThat(layered.getElement(veryFirst.getKey())).isEqualTo(veryFirst); + expected.addFirst(veryFirst); + soft.assertThat(newArrayList(layered.elementIterator())).containsExactlyElementsOf(expected); + soft.assertThat(newArrayList(layered.reverseElementIterator())) + .containsExactlyElementsOf(expected.reversed()); + soft.assertThat(layered.asKeyList().size()).isEqualTo(reference.asKeyList().size() + 1); + + soft.assertThat(layered.first()).isEqualTo(veryFirst.getKey()); + soft.assertThat(layered.last()).isEqualTo(referenceLast); + + var veryLast = indexElement(key("zzzzzzzzz"), randomObjId()); + embedded.add(veryLast); + + soft.assertThat(layered.containsElement(veryLast.getKey())).isTrue(); + soft.assertThat(layered.getElement(veryLast.getKey())).isEqualTo(veryLast); + expected.add(veryLast); + soft.assertThat(newArrayList(layered.elementIterator())).containsExactlyElementsOf(expected); + soft.assertThat(newArrayList(layered.reverseElementIterator())) + .containsExactlyElementsOf(expected.reversed()); + soft.assertThat(layered.asKeyList().size()).isEqualTo(reference.asKeyList().size() + 2); + + soft.assertThat(layered.first()).isEqualTo(veryFirst.getKey()); + soft.assertThat(layered.last()).isEqualTo(veryLast.getKey()); + } + + @Test + public void firstLastEstimated() { + var index1 = newStoreIndex(OBJ_REF_SERIALIZER); + var index2 = newStoreIndex(OBJ_REF_SERIALIZER); + var index3 = newStoreIndex(OBJ_REF_SERIALIZER); + index1.add(indexElement(key("aaa"), randomObjId())); + index2.add(indexElement(key("bbb"), randomObjId())); + + soft.assertThat(layeredIndex(index1, index2).first()).isEqualTo(index1.first()); + soft.assertThat(layeredIndex(index2, index1).first()).isEqualTo(index1.first()); + soft.assertThat(layeredIndex(index1, index2).last()).isEqualTo(index2.first()); + soft.assertThat(layeredIndex(index2, index1).last()).isEqualTo(index2.first()); + + soft.assertThat(layeredIndex(index1, index3).first()).isEqualTo(index1.first()); + soft.assertThat(layeredIndex(index3, index1).first()).isEqualTo(index1.first()); + soft.assertThat(layeredIndex(index1, index3).last()).isEqualTo(index1.first()); + soft.assertThat(layeredIndex(index3, index1).last()).isEqualTo(index1.first()); + + soft.assertThat(layeredIndex(index3, index1).estimatedSerializedSize()) + .isEqualTo(index3.estimatedSerializedSize() + index1.estimatedSerializedSize()); + soft.assertThat(layeredIndex(index1, index3).estimatedSerializedSize()) + .isEqualTo(index3.estimatedSerializedSize() + index1.estimatedSerializedSize()); + soft.assertThat(layeredIndex(index2, index1).estimatedSerializedSize()) + .isEqualTo(index2.estimatedSerializedSize() + index1.estimatedSerializedSize()); + soft.assertThat(layeredIndex(index1, index2).estimatedSerializedSize()) + .isEqualTo(index2.estimatedSerializedSize() + index1.estimatedSerializedSize()); + } + + @Test + public void stateRelated() { + var index1 = newStoreIndex(OBJ_REF_SERIALIZER); + var index2 = newStoreIndex(OBJ_REF_SERIALIZER); + var layered = layeredIndex(index1, index2); + + soft.assertThatThrownBy(layered::asMutableIndex) + .isInstanceOf(UnsupportedOperationException.class); + soft.assertThat(layered.isMutable()).isFalse(); + soft.assertThatThrownBy(() -> layered.divide(3)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + public void unsupported() { + var index1 = newStoreIndex(OBJ_REF_SERIALIZER); + var index2 = newStoreIndex(OBJ_REF_SERIALIZER); + var layered = layeredIndex(index1, index2); + + soft.assertThatThrownBy(layered::serialize).isInstanceOf(UnsupportedOperationException.class); + soft.assertThatThrownBy(() -> layered.add(indexElement(key("aaa"), randomObjId()))) + .isInstanceOf(UnsupportedOperationException.class); + soft.assertThatThrownBy(() -> layered.remove(key("aaa"))) + .isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestImmutableEmptyIndexImpl.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestImmutableEmptyIndexImpl.java new file mode 100644 index 0000000000..b5d9dcbde0 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestImmutableEmptyIndexImpl.java @@ -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. + */ +package org.apache.polaris.persistence.nosql.impl.indexes; + +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.key; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.deserializeStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.emptyImmutableIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.newStoreIndex; + +import java.nio.ByteBuffer; +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; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestImmutableEmptyIndexImpl { + @InjectSoftAssertions SoftAssertions soft; + + @Test + public void immutableEmpty() { + var index = emptyImmutableIndex(OBJ_REF_SERIALIZER); + + var commitOp = Util.randomObjId(); + + soft.assertThat(index.isLoaded()).isTrue(); + soft.assertThat(index.isModified()).isFalse(); + soft.assertThat(index.first()).isNull(); + soft.assertThat(index.last()).isNull(); + soft.assertThat(index.estimatedSerializedSize()).isEqualTo(2); + soft.assertThat(index.serialize()).isEqualTo(ByteBuffer.wrap(new byte[] {(byte) 1, (byte) 0})); + soft.assertThat(deserializeStoreIndex(index.serialize(), OBJ_REF_SERIALIZER).asKeyList()) + .isEqualTo(index.asKeyList()); + soft.assertThat(index.asKeyList()).isEmpty(); + soft.assertThat(index.stripes()).isEmpty(); + soft.assertThatThrownBy(() -> index.add(indexElement(key("foo"), commitOp))) + .isInstanceOf(UnsupportedOperationException.class); + soft.assertThatThrownBy(() -> index.remove(key("foo"))) + .isInstanceOf(UnsupportedOperationException.class); + soft.assertThat(index.getElement(key("foo"))).isNull(); + soft.assertThat(index.containsElement(key("foo"))).isFalse(); + soft.assertThat(index.iterator(null, null, false)).isExhausted(); + } + + @Test + public void stateRelated() { + var index = emptyImmutableIndex(OBJ_REF_SERIALIZER); + + soft.assertThat(index.asMutableIndex()).isNotSameAs(index); + soft.assertThat(index.isMutable()).isFalse(); + soft.assertThatThrownBy(() -> index.divide(3)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + public void serialization() { + var index = emptyImmutableIndex(OBJ_REF_SERIALIZER); + var mutable = newStoreIndex(OBJ_REF_SERIALIZER); + soft.assertThat(index.serialize()).isEqualTo(mutable.serialize()); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestIndexImpl.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestIndexImpl.java new file mode 100644 index 0000000000..ec39c1b454 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestIndexImpl.java @@ -0,0 +1,879 @@ +/* + * 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.impl.indexes; + +import static java.lang.String.format; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.asList; +import static java.util.Objects.requireNonNull; +import static java.util.Spliterators.spliteratorUnknownSize; +import static java.util.stream.StreamSupport.stream; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.INDEX_KEY_SERIALIZER; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.key; +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.impl.indexes.IndexesInternal.deserializeStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.newStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.KeyIndexTestSet.basicIndexTestSet; +import static org.apache.polaris.persistence.nosql.impl.indexes.ObjTestValue.OBJ_TEST_SERIALIZER; +import static org.apache.polaris.persistence.nosql.impl.indexes.ObjTestValue.objTestValueFromString; +import static org.apache.polaris.persistence.nosql.impl.indexes.ObjTestValue.objTestValueOfSize; +import static org.apache.polaris.persistence.nosql.impl.indexes.Util.asHex; +import static org.apache.polaris.persistence.nosql.impl.indexes.Util.randomObjId; +import static org.assertj.core.groups.Tuple.tuple; + +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +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.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestIndexImpl { + @InjectSoftAssertions SoftAssertions soft; + + static Stream> lazyKeyPredecessor() { + return Stream.of( + asList( + // "a/" sequence ensures that 'b/ref- 11' (and 12) are not materialized as 'b/ref- 1' + // (and 2) + // (because of a bad predecessor) + "a/ref- 0", "a/ref- 1", "a/ref- 2", "a/ref- 10", "a/ref- 11", "a/ref- 12"), + asList( + // "b/" sequence ensures that 'a/over' is not materialized as 'a/ever' + // (because of a bad predecessor) + "b/be", "b/eire", "b/opt", "b/over", "b/salt")); + } + + @ParameterizedTest + @MethodSource("lazyKeyPredecessor") + void lazyKeyPredecessor(List keys) { + var index = newStoreIndex(OBJ_REF_SERIALIZER); + keys.stream().map(IndexKey::key).map(k -> indexElement(k, randomObjId())).forEach(index::add); + + var serialized = index.serialize(); + var deserialized = deserializeStoreIndex(serialized, OBJ_REF_SERIALIZER); + + soft.assertThat(deserialized.asKeyList()).containsExactlyElementsOf(index.asKeyList()); + soft.assertThat(deserialized).containsExactlyElementsOf(index); + } + + private static IndexSpi refs20() { + var segment = newStoreIndex(OBJ_REF_SERIALIZER); + for (var i = 0; i < 20; i++) { + segment.add(indexElement(key(format("refs-%10d", i)), randomObjId())); + } + return segment; + } + + @Test + public void entriesCompareAfterReserialize() { + var segment = refs20(); + var keyList = segment.asKeyList(); + + var serialized = segment.serialize(); + var deserialized = deserializeStoreIndex(serialized, OBJ_REF_SERIALIZER); + + for (var i = keyList.size() - 1; i >= 0; i--) { + var key = keyList.get(i); + soft.assertThat(deserialized.getElement(key)).isEqualTo(segment.getElement(key)); + } + } + + @Test + public void deserialized() { + var segment = refs20(); + + var serialized = segment.serialize(); + var deserialized = deserializeStoreIndex(serialized, OBJ_REF_SERIALIZER); + soft.assertThat(deserialized.asKeyList()).containsExactlyElementsOf(segment.asKeyList()); + soft.assertThat(deserialized).isEqualTo(segment); + } + + @Test + public void reserialize() { + var segment = refs20(); + + var serialized = segment.serialize(); + var deserialized = deserializeStoreIndex(serialized, OBJ_REF_SERIALIZER); + ((IndexImpl) deserialized).setModified(); + var serialized2 = deserialized.serialize(); + + soft.assertThat(serialized2).isEqualTo(serialized); + } + + @Test + public void reserializeUnmodified() { + var segment = refs20(); + + var serialized = segment.serialize(); + var deserialized = deserializeStoreIndex(serialized, OBJ_REF_SERIALIZER); + var serialized2 = deserialized.serialize(); + + soft.assertThat(serialized2).isEqualTo(serialized); + } + + @Test + public void addKeysIntoIndex() { + var keyIndexTestSet = + KeyIndexTestSet.newGenerator() + .keySet( + ImmutableRealisticKeySet.builder() + .namespaceLevels(1) + .foldersPerLevel(1) + .tablesPerNamespace(5) + .deterministic(false) + .build()) + .elementSupplier(key -> indexElement(key, randomObjId())) + .elementSerializer(OBJ_REF_SERIALIZER) + .build() + .generateIndexTestSet(); + + var deserialized = keyIndexTestSet.deserialize(); + for (var c = 'a'; c <= 'z'; c++) { + deserialized.add(indexElement(key(c + "x-key"), randomObjId())); + } + + var serialized = deserialized.serialize(); + var reserialized = deserializeStoreIndex(serialized, OBJ_REF_SERIALIZER); + soft.assertThat(reserialized.asKeyList()).containsExactlyElementsOf(deserialized.asKeyList()); + soft.assertThat(reserialized).containsExactlyElementsOf(deserialized); + } + + @Test + public void removeKeysFromIndex() { + var keyIndexTestSet = + KeyIndexTestSet.newGenerator() + .keySet( + ImmutableRealisticKeySet.builder() + .namespaceLevels(3) + .foldersPerLevel(3) + .tablesPerNamespace(5) + .deterministic(false) + .build()) + .elementSupplier(key -> indexElement(key, randomObjId())) + .elementSerializer(OBJ_REF_SERIALIZER) + .build() + .generateIndexTestSet(); + + var deserialized = keyIndexTestSet.deserialize(); + var allKeys = keyIndexTestSet.keys(); + for (int i = 0; i < 10; i++) { + deserialized.remove(allKeys.get(10 * i)); + } + + var serialized = deserialized.serialize(); + var reserialized = deserializeStoreIndex(serialized, OBJ_REF_SERIALIZER); + soft.assertThat(reserialized.asKeyList()).containsExactlyElementsOf(deserialized.asKeyList()); + soft.assertThat(reserialized).containsExactlyElementsOf(deserialized); + } + + @Test + public void randomGetKey() { + var keyIndexTestSet = + KeyIndexTestSet.newGenerator() + .keySet( + ImmutableRealisticKeySet.builder() + .namespaceLevels(5) + .foldersPerLevel(5) + .tablesPerNamespace(5) + .deterministic(true) + .build()) + .elementSupplier(key -> indexElement(key, randomObjId())) + .elementSerializer(OBJ_REF_SERIALIZER) + .build() + .generateIndexTestSet(); + + for (var i = 0; i < 50; i++) { + var deserialized = keyIndexTestSet.deserialize(); + deserialized.getElement(keyIndexTestSet.randomKey()); + } + + var deserialized = keyIndexTestSet.deserialize(); + for (var i = 0; i < 50; i++) { + deserialized.getElement(keyIndexTestSet.randomKey()); + } + } + + @Test + public void similarPrefixLengths() { + var keyA = key("axA"); + var keyB = key("bxA"); + var keyC = key("cxA"); + var keyD = key("dxA"); + var keyE = key("exA"); + var keyExB = key("exB"); + var keyExD = key("exD"); + var keyEyC = key("eyC"); + var keyExC = key("exC"); + var segment = newStoreIndex(OBJ_REF_SERIALIZER); + Stream.of(keyA, keyB, keyC, keyD, keyE, keyExB, keyExD, keyEyC, keyExC) + .map(k -> indexElement(k, randomObjId())) + .forEach(segment::add); + + var serialized = segment.serialize(); + var deserialized = deserializeStoreIndex(serialized, OBJ_REF_SERIALIZER); + soft.assertThat(deserialized).isEqualTo(segment); + soft.assertThat(deserialized.serialize()).isEqualTo(serialized); + + deserialized = deserializeStoreIndex(serialized, OBJ_REF_SERIALIZER); + soft.assertThat(deserialized.asKeyList()).containsExactlyElementsOf(segment.asKeyList()); + soft.assertThat(deserialized.serialize()).isEqualTo(serialized); + } + + @Test + public void isModified() { + var segment = newStoreIndex(OBJ_REF_SERIALIZER); + soft.assertThat(segment.isModified()).isFalse(); + + segment = deserializeStoreIndex(segment.serialize(), OBJ_REF_SERIALIZER); + soft.assertThat(segment.isModified()).isFalse(); + segment.add(indexElement(key("foo"), randomObjId())); + soft.assertThat(segment.isModified()).isTrue(); + + segment = deserializeStoreIndex(segment.serialize(), OBJ_REF_SERIALIZER); + soft.assertThat(segment.isModified()).isFalse(); + segment.remove(key("foo")); + soft.assertThat(segment.isModified()).isTrue(); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100, 1000}) + public void indexKeyToIndexKeyIndex(int numKeys) { + var keys = IntStream.range(0, numKeys).mapToObj(IndexKey::key).toList(); + var values = IntStream.range(0, numKeys).mapToObj(i -> key("value-" + i)).toList(); + + var index = newStoreIndex(INDEX_KEY_SERIALIZER); + + for (int i = 0; i < keys.size(); i++) { + soft.assertThat(index.put(keys.get(i), values.get(i))).isTrue(); + } + + for (int i = 0; i < keys.size(); i++) { + soft.assertThat(index.contains(keys.get(i))).isTrue(); + soft.assertThat(index.get(keys.get(i))).isEqualTo(values.get(i)); + } + + var serialized = index.serialize(); + var deserialized = deserializeStoreIndex(serialized, INDEX_KEY_SERIALIZER); + + for (int i = 0; i < keys.size(); i++) { + soft.assertThat(deserialized.contains(keys.get(i))).isTrue(); + soft.assertThat(deserialized.get(keys.get(i))).isEqualTo(values.get(i)); + } + + var valuesAgain = new ArrayList(numKeys); + for (int i = 0; i < numKeys; i++) { + if ((i % 5) == 2) { + valuesAgain.add(key("value-UPDATED-" + i)); + } else { + valuesAgain.add(values.get(i)); + } + soft.assertThat(deserialized.put(keys.get(i), valuesAgain.get(i))).isFalse(); + } + + var serializedAgain = deserialized.serialize(); + var deserializedAgain = deserializeStoreIndex(serializedAgain, INDEX_KEY_SERIALIZER); + + for (int i = 0; i < keys.size(); i++) { + soft.assertThat(deserializedAgain.contains(keys.get(i))).isTrue(); + soft.assertThat(deserializedAgain.get(keys.get(i))).isEqualTo(valuesAgain.get(i)); + } + } + + @Test + public void keyIndexSegment() { + var segment = newStoreIndex(OBJ_TEST_SERIALIZER); + var id1 = objTestValueFromString("12345678"); + var id2 = + objTestValueFromString("1234567812345678123456781234567812345678123456781234567812345678"); + var id3 = + objTestValueFromString("1111111122222222111111112222222211111111222222221111111122222222"); + var id4 = objTestValueOfSize(256); + + var keyA = key("axA"); + var keyB = key("bxA"); + var keyC = key("cxA"); + var keyD = key("dxA"); + var keyE = key("exA"); + var keyExB = key("exB"); + var keyExD = key("exD"); + var keyEyC = key("eyC"); + var keyExC = key("exC"); + var keyNotExist = key("doesnotexist"); + + var hexKeyA = HexFormat.of().formatHex(keyA.toString().getBytes(UTF_8)); + var hexKeyB = HexFormat.of().formatHex(keyB.toString().getBytes(UTF_8)); + var hexKeyC = HexFormat.of().formatHex(keyC.toString().getBytes(UTF_8)); + var hexKeyD = HexFormat.of().formatHex(keyD.toString().getBytes(UTF_8)); + var hexKeyE = HexFormat.of().formatHex(keyE.toString().getBytes(UTF_8)); + + var serializationFormatVersion = "01"; + + var serializedA = + hexKeyA + + "01" // IndexKey.EOF + + "04" // 4 bytes key + + id1; + var serializedB = + hexKeyB + + "01" // IndexKey.EOF + + "20" // 32 bytes key + + id2; + var serializedC = + hexKeyC + + "01" // IndexKey.EOF + + "20" // 32 bytes key + + id3; + var serializedD = + hexKeyD + + "01" // IndexKey.EOF + + "8002" // varint - 256 bytes key (0 == 256 here!) + + id4; + var serializedE = + hexKeyE + + "01" // IndexKey.EOF + + "04" // 4 bytes key + + id1; + var serializedExB = + "42" + + "01" // IndexKey.EOF + + "20" // 32 bytes key + + id2; + var serializedExD = + "44" + + "01" // IndexKey.EOF + + "20" // 32 bytes key + + id3; + var serializedEyC = + "7943" + + "01" // IndexKey.EOF + + "8002" // varint - 256 bytes key (0 == 256 here!) + + id4; + var serializedExC = + "43" + + "01" // IndexKey.EOF + + "04" // 4 bytes key + + id1; + var serializedExCmodified = + "43" + + "01" // IndexKey.EOF + + "20" // 32 bytes key + + id2; + + Function, IndexSpi> reSerialize = + seg -> deserializeStoreIndex(seg.serialize(), OBJ_TEST_SERIALIZER); + + soft.assertThat(asHex(segment.serialize())).isEqualTo(serializationFormatVersion + "00"); + soft.assertThat(reSerialize.apply(segment)).isEqualTo(segment); + soft.assertThat(segment.asKeyList()).isEmpty(); + + soft.assertThat(segment.add(indexElement(keyD, id4))).isTrue(); + soft.assertThat(reSerialize.apply(segment)).isEqualTo(segment); + soft.assertThat(segment.asKeyList()).containsExactly(keyD); + soft.assertThat(asHex(segment.serialize())) + .isEqualTo( + serializationFormatVersion // + + "01" + + serializedD); + + soft.assertThat(segment.add(indexElement(keyB, id2))).isTrue(); + soft.assertThat(reSerialize.apply(segment)).isEqualTo(segment); + soft.assertThat(segment.asKeyList()).containsExactly(keyB, keyD); + soft.assertThat(asHex(segment.serialize())) + .isEqualTo( + serializationFormatVersion // + + "02" + + serializedB + + "04" // strip + + serializedD); + + soft.assertThat(segment.add(indexElement(keyC, id3))).isTrue(); + soft.assertThat(reSerialize.apply(segment)).isEqualTo(segment); + soft.assertThat(segment.asKeyList()).containsExactly(keyB, keyC, keyD); + soft.assertThat(asHex(segment.serialize())) + .isEqualTo( + serializationFormatVersion // + + "03" + + serializedB + + "04" // strip + + serializedC + + "04" // strip + + serializedD); + + soft.assertThat(segment.add(indexElement(keyE, id1))).isTrue(); + soft.assertThat(reSerialize.apply(segment)).isEqualTo(segment); + soft.assertThat(segment.asKeyList()).containsExactly(keyB, keyC, keyD, keyE); + soft.assertThat(asHex(segment.serialize())) + .isEqualTo( + serializationFormatVersion // + + "04" + + serializedB + + "04" // strip + + serializedC + + "04" // strip + + serializedD + + "04" // strip + + serializedE); + + soft.assertThat(segment.add(indexElement(keyA, id1))).isTrue(); + soft.assertThat(reSerialize.apply(segment)).isEqualTo(segment); + soft.assertThat(segment.asKeyList()).containsExactly(keyA, keyB, keyC, keyD, keyE); + soft.assertThat(asHex(segment.serialize())) + .isEqualTo( + serializationFormatVersion // + + "05" + + serializedA + + "04" // strip + + serializedB + + "04" // strip + + serializedC + + "04" // strip + + serializedD + + "04" // strip + + serializedE); + + soft.assertThat(segment.add(indexElement(keyExB, id2))).isTrue(); + soft.assertThat(reSerialize.apply(segment)).isEqualTo(segment); + soft.assertThat(segment.asKeyList()).containsExactly(keyA, keyB, keyC, keyD, keyE, keyExB); + soft.assertThat(asHex(segment.serialize())) + .isEqualTo( + serializationFormatVersion // + + "06" + + serializedA + + "04" // strip + + serializedB + + "04" // strip + + serializedC + + "04" // strip + + serializedD + + "04" // strip + + serializedE + + "02" // strip + + serializedExB); + + soft.assertThat(segment.add(indexElement(keyExD, id3))).isTrue(); + soft.assertThat(reSerialize.apply(segment)).isEqualTo(segment); + soft.assertThat(segment.asKeyList()) + .containsExactly(keyA, keyB, keyC, keyD, keyE, keyExB, keyExD); + soft.assertThat(asHex(segment.serialize())) + .isEqualTo( + serializationFormatVersion // + + "07" + + serializedA + + "04" // strip + + serializedB + + "04" // strip + + serializedC + + "04" // strip + + serializedD + + "04" // strip + + serializedE + + "02" // strip + + serializedExB + + "02" // strip + + serializedExD); + + soft.assertThat(segment.add(indexElement(keyEyC, id4))).isTrue(); + soft.assertThat(reSerialize.apply(segment)).isEqualTo(segment); + soft.assertThat(segment.asKeyList()) + .containsExactly(keyA, keyB, keyC, keyD, keyE, keyExB, keyExD, keyEyC); + soft.assertThat(asHex(segment.serialize())) + .isEqualTo( + serializationFormatVersion // + + "08" + + serializedA + + "04" // add + + serializedB + + "04" // add + + serializedC + + "04" // add + + serializedD + + "04" // add + + serializedE + + "02" // strip + + serializedExB + + "02" // strip + + serializedExD + + "03" // strip + + serializedEyC); + + soft.assertThat(segment.add(indexElement(keyExC, id1))).isTrue(); + soft.assertThat(reSerialize.apply(segment)).isEqualTo(segment); + soft.assertThat(segment.asKeyList()) + .containsExactly(keyA, keyB, keyC, keyD, keyE, keyExB, keyExC, keyExD, keyEyC); + soft.assertThat(asHex(segment.serialize())) + .isEqualTo( + serializationFormatVersion // + + "09" + + serializedA + + "04" // add + + serializedB + + "04" // add + + serializedC + + "04" // add + + serializedD + + "04" // add + + serializedE + + "02" // strip + + serializedExB + + "02" // strip + + serializedExC + + "02" // strip + + serializedExD + + "03" // strip + + serializedEyC); + soft.assertThat(segment.getElement(keyExC)).isEqualTo(indexElement(keyExC, id1)); + + // Re-add with a BIGGER serialized object-id + soft.assertThat(segment.add(indexElement(keyExC, id2))).isFalse(); + soft.assertThat(reSerialize.apply(segment)).isEqualTo(segment); + soft.assertThat(segment.asKeyList()) + .containsExactly(keyA, keyB, keyC, keyD, keyE, keyExB, keyExC, keyExD, keyEyC); + soft.assertThat(asHex(segment.serialize())) + .isEqualTo( + serializationFormatVersion // + + "09" + + serializedA + + "04" // add + + serializedB + + "04" // add + + serializedC + + "04" // add + + serializedD + + "04" // add + + serializedE + + "02" // strip + + serializedExB + + "02" // strip + + serializedExCmodified + + "02" // strip + + serializedExD + + "03" // strip + + serializedEyC); + soft.assertThat(segment.getElement(keyExC)).isEqualTo(indexElement(keyExC, id2)); + + soft.assertThat(segment.remove(keyNotExist)).isFalse(); + soft.assertThat(reSerialize.apply(segment)).isEqualTo(segment); + soft.assertThat(segment.asKeyList()) + .containsExactly(keyA, keyB, keyC, keyD, keyE, keyExB, keyExC, keyExD, keyEyC); + soft.assertThat(segment.containsElement(keyNotExist)).isFalse(); + + soft.assertThat(segment.remove(keyD)).isTrue(); + soft.assertThat(reSerialize.apply(segment)).isEqualTo(segment); + soft.assertThat(segment.asKeyList().size()).isEqualTo(8); + soft.assertThat(asHex(segment.serialize())) + .isEqualTo( + serializationFormatVersion // + + "08" + + serializedA + + "04" // add + + serializedB + + "04" // add + + serializedC + + "04" // add + + serializedE + + "02" // strip + + serializedExB + + "02" // strip + + serializedExCmodified + + "02" // strip + + serializedExD + + "03" // strip + + serializedEyC); + soft.assertThat(segment.asKeyList()) + .containsExactly(keyA, keyB, keyC, keyE, keyExB, keyExC, keyExD, keyEyC); + soft.assertThat(segment.containsElement(keyD)).isFalse(); + soft.assertThat(segment.containsElement(keyNotExist)).isFalse(); + soft.assertThat(segment.getElement(keyD)).isNull(); + } + + @Test + public void getFirstLast() { + var index = newStoreIndex(OBJ_REF_SERIALIZER); + + var id = randomObjId(); + for (var e1 = 'j'; e1 >= 'a'; e1--) { + for (var e2 = 'J'; e2 >= 'A'; e2--) { + var key = key("" + e1 + e2); + index.add(indexElement(key, id)); + } + } + + soft.assertThat(index.asKeyList().size()).isEqualTo(10 * 10); + soft.assertThat(index.first()).isEqualTo(key("aA")); + soft.assertThat(index.last()).isEqualTo(key("jJ")); + } + + @Test + public void iterator() { + var index = newStoreIndex(OBJ_REF_SERIALIZER); + + var id = randomObjId(); + for (var e1 = 'j'; e1 >= 'a'; e1--) { + for (var e2 = 'J'; e2 >= 'A'; e2--) { + var key = key("" + e1 + e2); + index.add(indexElement(key, id)); + } + } + var allKeys = new ArrayList<>(index.asKeyList()); + + soft.assertThat(index.asKeyList().size()).isEqualTo(10 * 10); + + soft.assertThatIterable(index).hasSize(10 * 10); + soft.assertThatIterator(index.iterator(null, null, false)).toIterable().hasSize(10 * 10); + + for (var pairs : + List.of( + tuple(key("a0"), key("a"), 0), + tuple(key("0"), key("9"), 0), + tuple(key("jJ"), key("k"), 1), + tuple(key("aA"), key("aA"), 1), + tuple(key("bB"), key("bB"), 1), + tuple(key("b"), key("bB"), 2), + tuple(key("aC"), key("aZ"), 8), + tuple(key("j"), null, 10), + tuple(key("b"), key("b"), 10), + tuple(key("b"), key("c"), 10), + tuple(key("b"), key("cA"), 11), + tuple(key("b"), key("j"), 8 * 10), + tuple(key("a"), key("j"), 9 * 10), + tuple(null, key("j"), 9 * 10), + tuple(null, null, 10 * 10), + tuple(key("a"), null, 10 * 10))) { + var lower = (IndexKey) pairs.toList().get(0); + var higher = (IndexKey) pairs.toList().get(1); + var size = (int) pairs.toList().get(2); + + var prefix = lower != null && lower.equals(higher); + var expected = + prefix + ? allKeys.stream().filter(k -> k.startsWith(lower)).toList() + : allKeys.stream() + .filter( + k -> + (lower == null || k.compareTo(lower) >= 0) + && (higher == null || k.compareTo(higher) <= 0)) + .toList(); + + soft.assertThatIterator(index.iterator(lower, higher, false)) + .toIterable() + .describedAs("%s..%s", lower, higher) + .hasSize(size) + .extracting(Map.Entry::getKey) + .containsExactlyElementsOf(expected); + + if (!prefix) { + soft.assertThatIterator(index.reverseIterator(lower, higher, false)) + .toIterable() + .describedAs("reverse %s..%s", lower, higher) + .hasSize(size) + .extracting(Map.Entry::getKey) + .containsExactlyElementsOf(expected.reversed()); + } else { + soft.assertThatIllegalArgumentException() + .isThrownBy(() -> index.reverseIterator(lower, higher, false)); + } + } + + soft.assertThatIllegalArgumentException() + .isThrownBy(() -> index.iterator(key("z"), key("a"), false)); + } + + @Test + public void updateAll() { + var indexTestSet = basicIndexTestSet(); + + soft.assertThat(indexTestSet.keyIndex()).isNotEmpty().allMatch(el -> el.getValue().id() > 0); + + var index = indexTestSet.keyIndex(); + for (var k : indexTestSet.keys()) { + var el = requireNonNull(index.get(k)); + index.put(k, objRef(el.type(), ~el.id(), 1)); + } + + soft.assertThat(indexTestSet.keyIndex()) + .hasSize(indexTestSet.keys().size()) + .allMatch(el -> el.getValue().id() < 0); + soft.assertThatIterator(indexTestSet.keyIndex().elementIterator()) + .toIterable() + .hasSize(indexTestSet.keys().size()) + .allMatch(el -> el.getValue().id() < 0); + + indexTestSet.keys().forEach(index::remove); + + soft.assertThat(indexTestSet.keyIndex()).isEmpty(); + } + + @Test + public void emptyIndexDivide() { + for (var i = -5; i < 5; i++) { + var parts = i; + soft.assertThatIllegalArgumentException() + .isThrownBy(() -> newStoreIndex(OBJ_REF_SERIALIZER).divide(parts)) + .withMessageStartingWith("Number of parts ") + .withMessageContaining( + " must be greater than 0 and less or equal to number of elements "); + } + } + + @Test + public void impossibleDivide() { + var indexTestSet = basicIndexTestSet(); + var index = indexTestSet.keyIndex(); + + soft.assertThatIllegalArgumentException() + .isThrownBy(() -> index.divide(index.asKeyList().size() + 1)) + .withMessageStartingWith("Number of parts ") + .withMessageContaining(" must be greater than 0 and less or equal to number of elements "); + } + + @ParameterizedTest + @ValueSource(ints = {2, 3, 4, 5, 6}) + public void divide(int parts) { + var indexTestSet = basicIndexTestSet(); + var index = indexTestSet.keyIndex(); + + var splits = index.divide(parts); + + soft.assertThat(splits.stream().mapToInt(i -> i.asKeyList().size()).sum()) + .isEqualTo(index.asKeyList().size()); + soft.assertThat(splits.stream().flatMap(i -> i.asKeyList().stream())) + .containsExactlyElementsOf(index.asKeyList()); + soft.assertThat( + splits.stream().flatMap(i -> stream(spliteratorUnknownSize(i.iterator(), 0), false))) + .containsExactlyElementsOf(index); + soft.assertThat(splits.getFirst().first()).isEqualTo(index.first()); + soft.assertThat(splits.getLast().last()).isEqualTo(index.last()); + } + + @Test + public void stateRelated() { + var indexTestSet = basicIndexTestSet(); + var index = indexTestSet.keyIndex(); + + soft.assertThat(index.asMutableIndex()).isSameAs(index); + soft.assertThat(index.isMutable()).isTrue(); + soft.assertThatCode(() -> index.divide(3)).doesNotThrowAnyException(); + } + + // The following multithreaded "tests" are only there to verify that no ByteBuffer related + // exceptions are thrown. + + @Test + public void multithreadedGetKey() throws Exception { + multithreaded(KeyIndexTestSet::randomGetKey, true); + } + + @Test + public void multithreadedSerialize() throws Exception { + multithreaded(KeyIndexTestSet::serialize, false); + } + + @Test + public void multithreadedFirst() throws Exception { + multithreaded(ts -> ts.keyIndex().first(), false); + } + + @Test + public void multithreadedLast() throws Exception { + multithreaded(ts -> ts.keyIndex().last(), false); + } + + @Test + public void multithreadedKeys() throws Exception { + multithreaded(ts -> ts.keyIndex().asKeyList(), false); + } + + @Test + public void multithreadedElementIterator() throws Exception { + multithreaded(ts -> ts.keyIndex().elementIterator().forEachRemaining(el -> {}), false); + } + + @Test + public void multithreadedIterator() throws Exception { + multithreaded(ts -> ts.keyIndex().iterator().forEachRemaining(el -> {}), false); + } + + void multithreaded(Consumer> worker, boolean longTest) throws Exception { + var indexTestSet = + KeyIndexTestSet.newGenerator() + .keySet(ImmutableRandomUuidKeySet.builder().numKeys(100_000).build()) + .elementSupplier(key -> indexElement(key, Util.randomObjId())) + .elementSerializer(OBJ_REF_SERIALIZER) + .build() + .generateIndexTestSet(); + + var threads = Runtime.getRuntime().availableProcessors(); + + try (var executor = Executors.newFixedThreadPool(threads)) { + var latch = new CountDownLatch(threads); + var start = new Semaphore(0); + var stop = new AtomicBoolean(); + + var futures = + IntStream.range(0, threads) + .mapToObj( + i -> + CompletableFuture.runAsync( + () -> { + latch.countDown(); + try { + start.acquire(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + while (!stop.get()) { + worker.accept(indexTestSet); + } + }, + executor)) + .toArray(CompletableFuture[]::new); + + latch.await(); + start.release(threads); + + Thread.sleep(longTest ? TimeUnit.SECONDS.toMillis(3) : 500L); + + stop.set(true); + + CompletableFuture.allOf(futures).get(); + } + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestKeyIndexSets.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestKeyIndexSets.java new file mode 100644 index 0000000000..43139b07ee --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestKeyIndexSets.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.impl.indexes; + +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.key; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; +import static org.apache.polaris.persistence.nosql.impl.indexes.Util.randomObjId; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.util.stream.Stream; +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.Timeout; +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 TestKeyIndexSets { + @InjectSoftAssertions SoftAssertions soft; + + @ParameterizedTest + @MethodSource("keyIndexSetConfigs") + @Timeout(30) // if this test hits the timeout, then that's a legit bug !! + void keyIndexSetTests( + int namespaceLevels, int foldersPerLevel, int tablesPerNamespace, boolean deterministic) { + + var keyIndexTestSet = + KeyIndexTestSet.newGenerator() + .keySet( + ImmutableRealisticKeySet.builder() + .namespaceLevels(namespaceLevels) + .foldersPerLevel(foldersPerLevel) + .tablesPerNamespace(tablesPerNamespace) + .deterministic(deterministic) + .build()) + .elementSupplier(key -> indexElement(key, randomObjId())) + .elementSerializer(OBJ_REF_SERIALIZER) + .build() + .generateIndexTestSet(); + + soft.assertThatCode(keyIndexTestSet::serialize).doesNotThrowAnyException(); + soft.assertThatCode(keyIndexTestSet::deserialize).doesNotThrowAnyException(); + soft.assertThat(((IndexImpl) keyIndexTestSet.deserialize()).setModified().serialize()) + .isEqualTo(keyIndexTestSet.serializedSafe()); + soft.assertThatCode(keyIndexTestSet::randomGetKey).doesNotThrowAnyException(); + soft.assertThatCode( + () -> { + IndexSpi deserialized = keyIndexTestSet.deserialize(); + deserialized.add(indexElement(key("zzzzzzzkey"), randomObjId())); + deserialized.serialize(); + }) + .doesNotThrowAnyException(); + soft.assertThatCode( + () -> { + IndexSpi deserialized = keyIndexTestSet.deserialize(); + for (char c = 'a'; c <= 'z'; c++) { + deserialized.add(indexElement(key(c + "xkey"), randomObjId())); + } + deserialized.serialize(); + }) + .doesNotThrowAnyException(); + } + + static Stream keyIndexSetConfigs() { + return Stream.of( + arguments(2, 2, 5, true), + arguments(2, 2, 5, false), + arguments(2, 2, 20, true), + arguments(2, 2, 20, false), + arguments(5, 5, 50, true), + arguments(5, 5, 50, false)); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestLazyIndexImpl.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestLazyIndexImpl.java new file mode 100644 index 0000000000..a6f260e2d2 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestLazyIndexImpl.java @@ -0,0 +1,229 @@ +/* + * 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.impl.indexes; + +import static java.util.Spliterators.spliteratorUnknownSize; +import static java.util.stream.StreamSupport.stream; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.key; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.lazyStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.newStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.KeyIndexTestSet.basicIndexTestSet; +import static org.apache.polaris.persistence.nosql.impl.indexes.Util.randomObjId; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.stream.Stream; +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.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; +import org.junit.jupiter.params.provider.ValueSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestLazyIndexImpl { + + @InjectSoftAssertions SoftAssertions soft; + + private static IndexSpi commonIndex; + + @BeforeAll + static void setup() { + commonIndex = basicIndexTestSet().keyIndex(); + } + + static final class Checker implements Supplier> { + final AtomicInteger called = new AtomicInteger(); + + @Override + public IndexSpi get() { + called.incrementAndGet(); + return commonIndex; + } + } + + static final class FailChecker implements Supplier> { + final AtomicInteger called = new AtomicInteger(); + + @Override + public IndexSpi get() { + called.incrementAndGet(); + throw new RuntimeException("fail check"); + } + } + + @SuppressWarnings("ReturnValueIgnored") + static Stream lazyCalls() { + return Stream.of( + arguments((Consumer>) IndexSpi::stripes, "stripes"), + arguments( + (Consumer>) IndexSpi::estimatedSerializedSize, + "estimatedSerializedSize"), + arguments( + (Consumer>) i -> i.add(indexElement(key("foo"), randomObjId())), + "add"), + arguments((Consumer>) i -> i.remove(key("foo")), "remove"), + arguments((Consumer>) i -> i.contains(key("foo")), "contains"), + arguments((Consumer>) i -> i.getElement(key("foo")), "get"), + arguments((Consumer>) IndexSpi::first, "first"), + arguments((Consumer>) IndexSpi::last, "last"), + arguments((Consumer>) IndexSpi::asKeyList, "asKeyList"), + arguments((Consumer>) IndexSpi::iterator, "iterator"), + arguments((Consumer>) i -> i.iterator(null, null, false), "iterator"), + arguments((Consumer>) IndexSpi::serialize, "serialize")); + } + + @ParameterizedTest + @MethodSource("lazyCalls") + void calls(Consumer> invoker, String ignore) { + var checker = new Checker(); + var lazyIndex = lazyStoreIndex(checker, null, null); + + soft.assertThat(checker.called).hasValue(0); + invoker.accept(lazyIndex); + soft.assertThat(checker.called).hasValue(1); + invoker.accept(lazyIndex); + soft.assertThat(checker.called).hasValue(1); + } + + @ParameterizedTest + @MethodSource("lazyCalls") + void fails(Consumer> invoker, String ignore) { + var checker = new FailChecker(); + var lazyIndex = lazyStoreIndex(checker, null, null); + + soft.assertThat(checker.called).hasValue(0); + soft.assertThatThrownBy(() -> invoker.accept(lazyIndex)) + .isInstanceOf(RuntimeException.class) + .hasMessage("fail check"); + soft.assertThat(checker.called).hasValue(1); + soft.assertThatThrownBy(() -> invoker.accept(lazyIndex)) + .isInstanceOf(RuntimeException.class) + .hasMessage("fail check"); + soft.assertThat(checker.called).hasValue(1); + } + + @Test + public void stateRelated() { + var index = newStoreIndex(OBJ_REF_SERIALIZER); + var lazyIndex = lazyStoreIndex(() -> index, null, null); + + soft.assertThat(lazyIndex.isMutable()).isFalse(); + soft.assertThat(lazyIndex.asMutableIndex()).isSameAs(index); + soft.assertThat(lazyIndex.isMutable()).isTrue(); + } + + @ParameterizedTest + @ValueSource(ints = {2, 3, 4, 5, 6}) + public void divide(int parts) { + var indexTestSet = basicIndexTestSet(); + var base = indexTestSet.keyIndex(); + + var index = lazyStoreIndex(() -> base, base.first(), base.last()); + + var splits = index.divide(parts); + + soft.assertThat(splits.stream().mapToInt(i -> i.asKeyList().size()).sum()) + .isEqualTo(index.asKeyList().size()); + soft.assertThat(splits.stream().flatMap(i -> i.asKeyList().stream())) + .containsExactlyElementsOf(index.asKeyList()); + soft.assertThat( + splits.stream().flatMap(i -> stream(spliteratorUnknownSize(i.iterator(), 0), false))) + .containsExactlyElementsOf(index); + soft.assertThat(splits.getFirst().first()).isEqualTo(index.first()); + soft.assertThat(splits.getLast().last()).isEqualTo(index.last()); + } + + @Test + public void firstLastKeyDontLoad() { + var index = newStoreIndex(OBJ_REF_SERIALIZER); + var first = key("aaa"); + var last = key("zzz"); + index.add(indexElement(first, randomObjId())); + index.add(indexElement(last, randomObjId())); + var lazyIndex = lazyStoreIndex(() -> index, first, last); + + soft.assertThat(lazyIndex) + .asInstanceOf(type(IndexSpi.class)) + .extracting(IndexSpi::isLoaded, IndexSpi::isModified, IndexSpi::isMutable) + .containsExactly(false, false, false); + + soft.assertThat(lazyIndex.first()).isEqualTo(first); + soft.assertThat(lazyIndex) + .asInstanceOf(type(IndexSpi.class)) + .extracting(IndexSpi::isLoaded, IndexSpi::isModified, IndexSpi::isMutable) + .containsExactly(false, false, false); + + soft.assertThat(lazyIndex.last()).isEqualTo(last); + soft.assertThat(lazyIndex) + .asInstanceOf(type(IndexSpi.class)) + .extracting(IndexSpi::isLoaded, IndexSpi::isModified, IndexSpi::isMutable) + .containsExactly(false, false, false); + + soft.assertThat(lazyIndex.containsElement(first)).isTrue(); + soft.assertThat(lazyIndex) + .asInstanceOf(type(IndexSpi.class)) + .extracting(IndexSpi::isLoaded, IndexSpi::isModified, IndexSpi::isMutable) + .containsExactly(false, false, false); + + soft.assertThat(lazyIndex.containsElement(last)).isTrue(); + soft.assertThat(lazyIndex) + .asInstanceOf(type(IndexSpi.class)) + .extracting(IndexSpi::isLoaded, IndexSpi::isModified, IndexSpi::isMutable) + .containsExactly(false, false, false); + } + + @Test + public void firstLastKeyDoLoadIfNotSpecified() { + var index = newStoreIndex(OBJ_REF_SERIALIZER); + var first = key("aaa"); + var last = key("zzz"); + index.add(indexElement(first, randomObjId())); + index.add(indexElement(last, randomObjId())); + var lazyIndex = lazyStoreIndex(() -> index, null, null); + + soft.assertThat(lazyIndex) + .asInstanceOf(type(IndexSpi.class)) + .extracting(IndexSpi::isLoaded, IndexSpi::isModified, IndexSpi::isMutable) + .containsExactly(false, false, false); + + soft.assertThat(lazyIndex.first()).isEqualTo(first); + soft.assertThat(lazyIndex) + .asInstanceOf(type(IndexSpi.class)) + .extracting(IndexSpi::isLoaded, IndexSpi::isModified, IndexSpi::isMutable) + .containsExactly(true, true, true); + + lazyIndex = lazyStoreIndex(() -> index, null, null); + soft.assertThat(lazyIndex.last()).isEqualTo(last); + soft.assertThat(lazyIndex) + .asInstanceOf(type(IndexSpi.class)) + .extracting(IndexSpi::isLoaded, IndexSpi::isModified, IndexSpi::isMutable) + .containsExactly(true, true, true); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestReadOnlyLayeredIndexImpl.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestReadOnlyLayeredIndexImpl.java new file mode 100644 index 0000000000..a9d1791fc5 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestReadOnlyLayeredIndexImpl.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.impl.indexes; + +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.key; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.layeredIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.newStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.Util.randomObjId; + +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; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestReadOnlyLayeredIndexImpl { + @InjectSoftAssertions SoftAssertions soft; + + @Test + public void unsupported() { + var index1 = newStoreIndex(OBJ_REF_SERIALIZER); + var index2 = newStoreIndex(OBJ_REF_SERIALIZER); + var layered = layeredIndex(index1, index2); + + soft.assertThatThrownBy(layered::serialize).isInstanceOf(UnsupportedOperationException.class); + soft.assertThatThrownBy(() -> layered.add(indexElement(key("aaa"), randomObjId()))) + .isInstanceOf(UnsupportedOperationException.class); + soft.assertThatThrownBy(() -> layered.remove(key("aaa"))) + .isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestStripedIndexImpl.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestStripedIndexImpl.java new file mode 100644 index 0000000000..8a020417c8 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestStripedIndexImpl.java @@ -0,0 +1,538 @@ +/* + * 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.impl.indexes; + +import static com.google.common.collect.Lists.newArrayList; +import static java.util.Collections.singleton; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.key; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.OBJ_REF_SERIALIZER; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexLoader.notLoading; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.deserializeStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexFromStripes; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.lazyStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.newStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.KeyIndexTestSet.basicIndexTestSet; +import static org.assertj.core.api.InstanceOfAssertFactories.type; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +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.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestStripedIndexImpl { + @InjectSoftAssertions SoftAssertions soft; + + @Test + public void isLoadedReflectedLazy() { + IndexSpi reference = KeyIndexTestSet.basicIndexTestSet().keyIndex(); + + var originalStripesList = reference.divide(5); + Supplier>> stripesSupplier = + () -> + originalStripesList.stream() + .map(s -> deserializeStoreIndex(s.serialize(), OBJ_REF_SERIALIZER)) + .collect(Collectors.toList()); + var firstLastKeys = + stripesSupplier.get().stream() + .flatMap(s -> Stream.of(s.first(), s.last())) + .collect(Collectors.toList()); + + Supplier> stripedSupplier = + () -> { + var originalStripes = stripesSupplier.get(); + var stripes = + originalStripes.stream() + .map(s -> lazyStoreIndex(() -> s, null, null)) + .collect(Collectors.toList()); + return IndexesInternal.indexFromStripes( + stripes, + firstLastKeys, + indexes -> { + @SuppressWarnings("unchecked") + IndexSpi[] r = new IndexSpi[indexes.length]; + // Use reference equality in this test to identify the stripe to be "loaded" + for (var index : indexes) { + for (int i = 0; i < stripes.size(); i++) { + var lazyStripe = stripes.get(i); + if (lazyStripe == index) { + r[i] = originalStripes.get(i); + } + } + } + return r; + }); + }; + + var striped = stripedSupplier.get(); + soft.assertThat(striped.isLoaded()).isFalse(); + + for (var key : firstLastKeys) { + striped = stripedSupplier.get(); + soft.assertThat(striped.isLoaded()).isFalse(); + soft.assertThat(striped.containsElement(key)).isTrue(); + soft.assertThat(striped.isLoaded()).isTrue(); + } + + for (var s : stripesSupplier.get()) { + striped = stripedSupplier.get(); + var key = s.asKeyList().get(1); + soft.assertThat(striped.isLoaded()).isFalse(); + soft.assertThat(striped.containsElement(key)).isTrue(); + soft.assertThat(striped.isLoaded()).isTrue(); + + striped = stripedSupplier.get(); + soft.assertThat(striped.isLoaded()).isFalse(); + soft.assertThat(striped.getElement(key)).isNotNull(); + soft.assertThat(striped.isLoaded()).isTrue(); + } + } + + @Test + public void isLoadedReflectedEager() { + var reference = KeyIndexTestSet.basicIndexTestSet().keyIndex(); + + var originalStripes = reference.divide(5); + var firstLastKeys = + originalStripes.stream() + .flatMap(s -> Stream.of(s.first(), s.last())) + .collect(Collectors.toList()); + + Supplier> stripedSupplier; + + stripedSupplier = + () -> IndexesInternal.indexFromStripes(originalStripes, firstLastKeys, notLoading()); + + var striped = stripedSupplier.get(); + soft.assertThat(striped.isLoaded()).isTrue(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void isModifiedReflected(boolean lazyStripes) { + var reference = KeyIndexTestSet.basicIndexTestSet().keyIndex(); + + var originalStripesList = reference.divide(5); + Supplier>> stripesSupplier = + () -> + originalStripesList.stream() + .map(s -> deserializeStoreIndex(s.serialize(), OBJ_REF_SERIALIZER)) + .collect(Collectors.toList()); + var firstLastKeys = + stripesSupplier.get().stream() + .flatMap(s -> Stream.of(s.first(), s.last())) + .collect(Collectors.toList()); + + Supplier> stripedSupplier = + createStoreIndexSupplier(lazyStripes, stripesSupplier, firstLastKeys); + + var striped = stripedSupplier.get(); + soft.assertThat(striped.isModified()).isFalse(); + + for (var key : firstLastKeys) { + striped = stripedSupplier.get(); + soft.assertThat(striped.isModified()).isFalse(); + striped.add(indexElement(key, Util.randomObjId())); + soft.assertThat(striped.isModified()).isTrue(); + } + + for (var s : stripesSupplier.get()) { + striped = stripedSupplier.get(); + var key = s.asKeyList().get(1); + soft.assertThat(striped.isModified()).isFalse(); + striped.add(indexElement(key, Util.randomObjId())); + soft.assertThat(striped.isModified()).isTrue(); + } + } + + private static Supplier> createStoreIndexSupplier( + boolean lazyStripes, + Supplier>> stripesSupplier, + List firstLastKeys) { + Supplier> stripedSupplier; + if (lazyStripes) { + stripedSupplier = + () -> { + var originalStripes = stripesSupplier.get(); + var stripes = + originalStripes.stream() + .map(s -> lazyStoreIndex(() -> s, null, null)) + .collect(Collectors.toList()); + return IndexesInternal.indexFromStripes( + stripes, + firstLastKeys, + indexes -> { + @SuppressWarnings("unchecked") + IndexSpi[] r = new IndexSpi[indexes.length]; + // Use reference equality in this test to identify the stripe to be "loaded" + for (var index : indexes) { + for (int i = 0; i < stripes.size(); i++) { + var lazyStripe = stripes.get(i); + if (lazyStripe == index) { + r[i] = originalStripes.get(i); + } + } + } + return r; + }); + }; + } else { + stripedSupplier = + () -> + IndexesInternal.indexFromStripes(stripesSupplier.get(), firstLastKeys, notLoading()); + } + return stripedSupplier; + } + + @SuppressWarnings("ConstantConditions") + @ParameterizedTest + @ValueSource(ints = {2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}) + public void stripedLazy(int numStripes) { + var indexTestSet = basicIndexTestSet(); + + var striped = indexFromStripes(indexTestSet.keyIndex().divide(numStripes)); + var stripes = striped.stripes(); + + // Sanity checks + soft.assertThat(stripes).hasSize(numStripes); + + var individualLoads = new boolean[numStripes]; + var bulkLoads = new boolean[numStripes]; + + var firstLastKeys = + stripes.stream().flatMap(s -> Stream.of(s.first(), s.last())).collect(Collectors.toList()); + + // This supplier provides a striped-over-lazy-segments index. Individually loaded stripes + // are marked in 'individualLoads' and bulk-loaded stripes in 'bulkLoads'. + Supplier> lazyIndexSupplier = + () -> { + Arrays.fill(bulkLoads, false); + Arrays.fill(individualLoads, false); + + var lazyStripes = new ArrayList>(stripes.size()); + for (var i = 0; i < stripes.size(); i++) { + var stripe = stripes.get(i); + var index = i; + lazyStripes.add( + lazyStoreIndex( + () -> { + individualLoads[index] = true; + return stripe; + }, + null, + null)); + } + + return IndexesInternal.indexFromStripes( + lazyStripes, + firstLastKeys, + indexes -> { + @SuppressWarnings("unchecked") + IndexSpi[] r = new IndexSpi[indexes.length]; + for (var i = 0; i < indexes.length; i++) { + if (indexes[i] != null) { + bulkLoads[i] = true; + r[i] = stripes.get(i); + } + } + return r; + }); + }; + + for (var i = 0; i < stripes.size(); i++) { + var refStripe = stripes.get(i); + + var expectLoaded = new boolean[numStripes]; + expectLoaded[i] = true; + + var lazyStripedIndex = lazyIndexSupplier.get(); + lazyStripedIndex.prefetchIfNecessary(singleton(refStripe.first())); + soft.assertThat(lazyStripedIndex.containsElement(refStripe.first())).isTrue(); + soft.assertThat(bulkLoads).containsExactly(expectLoaded); + soft.assertThat(individualLoads).containsOnly(false); + + lazyStripedIndex = lazyIndexSupplier.get(); + lazyStripedIndex.prefetchIfNecessary(singleton(refStripe.last())); + soft.assertThat(lazyStripedIndex.containsElement(refStripe.last())).isTrue(); + soft.assertThat(bulkLoads).containsExactly(expectLoaded); + soft.assertThat(individualLoads).containsOnly(false); + + lazyStripedIndex = lazyIndexSupplier.get(); + lazyStripedIndex.prefetchIfNecessary(singleton(refStripe.first())); + soft.assertThat(lazyStripedIndex.getElement(refStripe.first())) + .extracting(IndexElement::getKey) + .isEqualTo(refStripe.first()); + soft.assertThat(bulkLoads).containsExactly(expectLoaded); + soft.assertThat(individualLoads).containsOnly(false); + + lazyStripedIndex = lazyIndexSupplier.get(); + lazyStripedIndex.prefetchIfNecessary(singleton(refStripe.last())); + soft.assertThat(lazyStripedIndex.getElement(refStripe.last())) + .extracting(IndexElement::getKey) + .isEqualTo(refStripe.last()); + soft.assertThat(bulkLoads).containsExactly(expectLoaded); + soft.assertThat(individualLoads).containsOnly(false); + } + + // A key before the first stripe's first key does NOT fire a load + { + var expectLoaded = new boolean[numStripes]; + var lazyStripedIndex = lazyIndexSupplier.get(); + var key = key(""); + lazyStripedIndex.prefetchIfNecessary(singleton(key)); + soft.assertThat(bulkLoads).containsExactly(expectLoaded); + soft.assertThat(individualLoads).containsOnly(false); + + lazyStripedIndex = lazyIndexSupplier.get(); + lazyStripedIndex.containsElement(key); + lazyStripedIndex.getElement(key); + soft.assertThat(bulkLoads).containsExactly(expectLoaded); + soft.assertThat(individualLoads).containsOnly(false); + } + + // A key after the last stripe's last key does NOT fire a load + { + var expectLoaded = new boolean[numStripes]; + var lazyStripedIndex = lazyIndexSupplier.get(); + var key = key("þZZZZ"); + lazyStripedIndex.prefetchIfNecessary(singleton(key)); + soft.assertThat(bulkLoads).containsExactly(expectLoaded); + soft.assertThat(individualLoads).containsOnly(false); + + lazyStripedIndex = lazyIndexSupplier.get(); + lazyStripedIndex.containsElement(key); + lazyStripedIndex.getElement(key); + soft.assertThat(bulkLoads).containsExactly(expectLoaded); + soft.assertThat(individualLoads).containsOnly(false); + } + + // Keys "between" stripes must NOT fire a load + for (var i = 0; i < stripes.size(); i++) { + var stripe = stripes.get(i); + var expectLoaded = new boolean[numStripes]; + + // Any key before between two stripes must not fire a load + var lazyStripedIndex = lazyIndexSupplier.get(); + var key = key(stripe.last() + "AA"); + lazyStripedIndex.prefetchIfNecessary(singleton(key)); + soft.assertThat(bulkLoads).containsExactly(expectLoaded); + soft.assertThat(individualLoads).containsOnly(false); + + // check contains() + get() + lazyStripedIndex = lazyIndexSupplier.get(); + lazyStripedIndex.containsElement(key); + lazyStripedIndex.getElement(key); + soft.assertThat(bulkLoads).containsExactly(expectLoaded); + soft.assertThat(individualLoads).containsOnly(false); + + // Any key in a stripe must fire a load + expectLoaded[i] = true; + lazyStripedIndex = lazyIndexSupplier.get(); + key = key(stripe.first() + "AA"); + lazyStripedIndex.prefetchIfNecessary(singleton(key)); + soft.assertThat(bulkLoads).containsExactly(expectLoaded); + soft.assertThat(individualLoads).containsOnly(false); + + // check contains() + get() + lazyStripedIndex = lazyIndexSupplier.get(); + lazyStripedIndex.containsElement(key); + soft.assertThat(bulkLoads).containsOnly(false); + soft.assertThat(individualLoads).containsExactly(expectLoaded); + lazyStripedIndex = lazyIndexSupplier.get(); + lazyStripedIndex.getElement(key); + soft.assertThat(bulkLoads).containsOnly(false); + soft.assertThat(individualLoads).containsExactly(expectLoaded); + } + + { + var expectLoaded = new boolean[numStripes]; + Arrays.fill(expectLoaded, true); + + var allFirstKeys = stripes.stream().map(IndexSpi::first).collect(Collectors.toSet()); + var lazyStripedIndex = lazyIndexSupplier.get(); + lazyStripedIndex.prefetchIfNecessary(allFirstKeys); + soft.assertThat(bulkLoads).containsExactly(expectLoaded); + soft.assertThat(individualLoads).containsOnly(false); + + var allLastKeys = stripes.stream().map(IndexSpi::last).collect(Collectors.toSet()); + lazyStripedIndex = lazyIndexSupplier.get(); + lazyStripedIndex.prefetchIfNecessary(allLastKeys); + soft.assertThat(bulkLoads).containsExactly(expectLoaded); + soft.assertThat(individualLoads).containsOnly(false); + } + } + + @ParameterizedTest + @ValueSource(ints = {2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}) + public void striped(int numStripes) { + var indexTestSet = basicIndexTestSet(); + + var source = indexTestSet.keyIndex(); + var striped = indexFromStripes(indexTestSet.keyIndex().divide(numStripes)); + + // Sanity checks + soft.assertThat(striped.stripes()).hasSize(numStripes); + + soft.assertThat(striped.asKeyList().size()).isEqualTo(source.asKeyList().size()); + soft.assertThat(striped.asKeyList()).containsExactlyElementsOf(source.asKeyList()); + soft.assertThat(striped.first()).isEqualTo(source.first()); + soft.assertThat(striped.last()).isEqualTo(source.last()); + soft.assertThat(striped).containsExactlyElementsOf(source); + soft.assertThatIterable(striped).isNotEmpty().containsExactlyElementsOf(source); + soft.assertThatIterator(striped.reverseIterator()) + .toIterable() + .isNotEmpty() + .containsExactlyElementsOf(newArrayList(source.reverseIterator())) + .containsExactlyElementsOf(newArrayList(source.iterator()).reversed()); + + soft.assertThat(striped.estimatedSerializedSize()) + .isEqualTo(striped.stripes().stream().mapToInt(IndexSpi::estimatedSerializedSize).sum()); + soft.assertThatThrownBy(striped::serialize).isInstanceOf(UnsupportedOperationException.class); + + for (IndexKey key : indexTestSet.keys()) { + soft.assertThat(striped.containsElement(key)).isTrue(); + soft.assertThat(striped.containsElement(key(key + "xyz"))).isFalse(); + soft.assertThat(striped.getElement(key)).isNotNull(); + + soft.assertThatIterator(striped.iterator(key, key, false)) + .toIterable() + .containsExactlyElementsOf(newArrayList(source.iterator(key, key, false))); + + var stripedFromKey = newArrayList(striped.iterator(key, null, false)); + var stripedReverseFromKey = newArrayList(striped.reverseIterator(key, null, false)); + var sourceFromKey = newArrayList(source.iterator(key, null, false)); + var sourceReverseFromKey = newArrayList(source.reverseIterator(key, null, false)); + var stripedToKey = newArrayList(striped.iterator(null, key, false)); + var sourceToKey = newArrayList(source.iterator(null, key, false)); + var stripedReverseToKey = newArrayList(striped.reverseIterator(null, key, false)); + var sourceReverseToKey = newArrayList(source.reverseIterator(null, key, false)); + + soft.assertThat(stripedFromKey).isNotEmpty().containsExactlyElementsOf(sourceFromKey); + soft.assertThat(stripedToKey).isNotEmpty().containsExactlyElementsOf(sourceToKey); + + soft.assertThat(stripedReverseFromKey) + .containsExactlyElementsOf(sourceReverseFromKey) + .containsExactlyElementsOf(sourceFromKey.reversed()); + soft.assertThat(stripedReverseToKey) + .containsExactlyElementsOf(sourceReverseToKey) + .containsExactlyElementsOf(sourceToKey.reversed()); + } + + var stripedFromStripes = indexFromStripes(striped.stripes()); + soft.assertThatIterable(stripedFromStripes).containsExactlyElementsOf(striped); + soft.assertThat(stripedFromStripes) + .asInstanceOf(type(IndexSpi.class)) + .extracting(IndexSpi::stripes, IndexSpi::asKeyList) + .containsExactly(striped.stripes(), striped.asKeyList()); + } + + @Test + public void stateRelated() { + var indexTestSet = basicIndexTestSet(); + var striped = indexFromStripes(indexTestSet.keyIndex().divide(3)); + + soft.assertThat(striped.asMutableIndex()).isSameAs(striped); + soft.assertThat(striped.isMutable()).isTrue(); + soft.assertThatThrownBy(() -> striped.divide(3)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void modifyingStripedRemoveIterative(boolean lazy) { + var indexTestSet = basicIndexTestSet(); + var source = indexTestSet.keyIndex(); + + var striped = indexFromStripes(indexTestSet.keyIndex().divide(3)); + if (lazy) { + var lazyStripes = + striped.stripes().stream() + .map(i -> lazyStoreIndex(() -> i, null, null)) + .collect(Collectors.toList()); + striped = indexFromStripes(lazyStripes); + } + + var keyList = source.asKeyList(); + var expectedElementCount = source.asKeyList().size(); + + while (!keyList.isEmpty()) { + var key = keyList.getFirst(); + source.remove(key); + striped.remove(key); + expectedElementCount--; + + soft.assertThatIterable(striped).containsExactlyElementsOf(source); + soft.assertThat(striped.asKeyList().size()) + .isEqualTo(source.asKeyList().size()) + .isEqualTo(expectedElementCount); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void modifyingStripedAdding(boolean lazy) { + var indexTestSet = basicIndexTestSet(); + var source = indexTestSet.keyIndex(); + + List> elements = newArrayList(source.elementIterator()); + + var indexEven = newStoreIndex(OBJ_REF_SERIALIZER); + var indexOdd = newStoreIndex(OBJ_REF_SERIALIZER); + + for (int i = 0; i < elements.size(); i += 2) { + indexEven.add(elements.get(i)); + } + for (int i = 1; i < elements.size(); i += 2) { + indexOdd.add(elements.get(i)); + } + + var striped = indexFromStripes(indexEven.divide(4)); + if (lazy) { + var lazyStripes = + striped.stripes().stream() + .map(i -> lazyStoreIndex(() -> i, null, null)) + .collect(Collectors.toList()); + striped = indexFromStripes(lazyStripes); + } + + soft.assertThatIterable(striped).containsExactlyElementsOf(newArrayList(indexEven)); + soft.assertThat(striped.asKeyList().size()) + .isEqualTo(source.asKeyList().size() / 2) + .isEqualTo(elements.size() / 2); + + indexOdd.elementIterator().forEachRemaining(striped::add); + + soft.assertThatIterable(striped).containsExactlyElementsOf(newArrayList(source)); + soft.assertThat(striped.asKeyList().size()) + .isEqualTo(source.asKeyList().size()) + .isEqualTo(elements.size()); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestSupplyOnce.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestSupplyOnce.java new file mode 100644 index 0000000000..22c4c2d99e --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestSupplyOnce.java @@ -0,0 +1,104 @@ +/* + * 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.impl.indexes; + +import static org.apache.polaris.persistence.nosql.impl.indexes.SupplyOnce.memoize; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +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; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestSupplyOnce { + @InjectSoftAssertions protected SoftAssertions soft; + + @Test + public void nullValue() { + AtomicInteger counter = new AtomicInteger(); + Supplier nullValue = + memoize( + () -> { + counter.incrementAndGet(); + return null; + }); + + soft.assertThat(counter).hasValue(0); + + soft.assertThat(nullValue.get()).isNull(); + soft.assertThat(counter).hasValue(1); + soft.assertThat(nullValue.get()).isNull(); + soft.assertThat(counter).hasValue(1); + soft.assertThat(nullValue.get()).isNull(); + soft.assertThat(counter).hasValue(1); + } + + @Test + public void someValue() { + AtomicInteger counter = new AtomicInteger(); + Supplier nullValue = + memoize( + () -> { + counter.incrementAndGet(); + return "foo"; + }); + + soft.assertThat(counter).hasValue(0); + + soft.assertThat(nullValue.get()).isEqualTo("foo"); + soft.assertThat(counter).hasValue(1); + soft.assertThat(nullValue.get()).isEqualTo("foo"); + soft.assertThat(counter).hasValue(1); + soft.assertThat(nullValue.get()).isEqualTo("foo"); + soft.assertThat(counter).hasValue(1); + } + + @Test + public void failure() { + AtomicInteger counter = new AtomicInteger(); + Supplier failure = + memoize( + () -> { + counter.incrementAndGet(); + throw new RuntimeException("foo"); + }); + + soft.assertThat(counter).hasValue(0); + + AtomicReference exceptionInstance = new AtomicReference<>(); + + soft.assertThatRuntimeException() + .isThrownBy(failure::get) + .extracting( + re -> { + exceptionInstance.set(re); + return re.getMessage(); + }) + .isEqualTo("foo"); + soft.assertThat(counter).hasValue(1); + soft.assertThatRuntimeException().isThrownBy(failure::get).isSameAs(exceptionInstance.get()); + soft.assertThat(counter).hasValue(1); + soft.assertThatRuntimeException().isThrownBy(failure::get).isSameAs(exceptionInstance.get()); + soft.assertThat(counter).hasValue(1); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestUpdatableIndexImpl.java b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestUpdatableIndexImpl.java new file mode 100644 index 0000000000..0ec420a94a --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestUpdatableIndexImpl.java @@ -0,0 +1,398 @@ +/* + * 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.impl.indexes; + +import static java.util.Objects.requireNonNull; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.key; +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.impl.indexes.IndexesInternal.deserializeStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.newStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.ObjTestValue.OBJ_TEST_SERIALIZER; +import static org.apache.polaris.persistence.nosql.impl.indexes.Util.randomObjId; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import com.google.common.collect.Streams; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.IntFunction; +import java.util.function.LongFunction; +import java.util.stream.IntStream; +import java.util.stream.LongStream; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.index.ImmutableIndexContainer; +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.IndexStripe; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; +import org.apache.polaris.persistence.nosql.api.obj.Obj; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.testextension.BackendSpec; +import org.apache.polaris.persistence.nosql.testextension.PersistenceTestExtension; +import org.apache.polaris.persistence.nosql.testextension.PolarisPersistence; +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({PersistenceTestExtension.class, SoftAssertionsExtension.class}) +@BackendSpec +public class TestUpdatableIndexImpl { + @InjectSoftAssertions SoftAssertions soft; + @PolarisPersistence protected Persistence persistence; + + @Test + public void emptyReferenceRemove() { + var foo = key("foo"); + var bar = key("bar"); + var baz = key("baz"); + var id1 = randomObjId(); + var id2 = randomObjId(); + var id3 = randomObjId(); + + var updatable = + updatableIndexForTest(Map.of(), Map.of(foo, id1, bar, id2, baz, id3), OBJ_REF_SERIALIZER); + + soft.assertThat(updatable.asKeyList()).containsExactly(bar, baz, foo); + soft.assertThat(updatable) + .containsExactly(Map.entry(bar, id2), Map.entry(baz, id3), Map.entry(foo, id1)); + + soft.assertThat(updatable.remove(baz)).isTrue(); + + soft.assertThat(updatable.asKeyList()).containsExactly(bar, foo); + + var indexed = updatable.toIndexed("idx-", (name, obj) -> soft.fail("Unexpected obj persist")); + var reserialized = indexed.indexForRead(persistence, OBJ_REF_SERIALIZER); + soft.assertThat(reserialized).containsExactly(Map.entry(bar, id2), Map.entry(foo, id1)); + } + + @Test + public void spillOutInitial() { + var index = IndexesProvider.buildWriteIndex(null, persistence, OBJ_REF_SERIALIZER); + var keyGen = (LongFunction) i -> IndexKey.key("x" + i + "y1234567890123456789"); + var objIdGen = (LongFunction) i -> objRef("foo", i, 1); + var elementsCrossingMaxEmbeddedSize = persistence.params().maxEmbeddedIndexSize().asLong() / 20; + var elementsCrossingMaxStripeSize = persistence.params().maxIndexStripeSize().asLong() / 20; + var num = elementsCrossingMaxEmbeddedSize + 5 * elementsCrossingMaxStripeSize; + for (var i = 0L; i < num; i++) { + index.put(keyGen.apply(i), objIdGen.apply(i)); + } + var stripes = new HashMap(); + var indexContainer = index.toIndexed("idx-", stripes::put); + + persistence.writeMany(Obj.class, stripes.values().toArray(Obj[]::new)); + + var readFake = IndexesProvider.buildReadIndex(indexContainer, persistence, OBJ_REF_SERIALIZER); + + assertThat(LongStream.range(0, num)) + .allMatch(i -> objIdGen.apply(i).equals(readFake.get(keyGen.apply(i)))); + } + + @ParameterizedTest + @MethodSource + public void bigIndex(int numIterations, int additionsPerIteration) { + var objIdGen = (IntFunction) i -> objRef("foo", i, 1); + var keyGen = (IntFunction) i -> IndexKey.key("my-table." + i + ".suffix"); + + var table = 0; + var currentIndexContainer = (IndexContainer) null; + for (var i = 0; i < numIterations; i++, table += additionsPerIteration) { + var index = + IndexesProvider.buildWriteIndex(currentIndexContainer, persistence, OBJ_REF_SERIALIZER); + + for (var t = table; t < table + additionsPerIteration; t++) { + index.put(keyGen.apply(t), objIdGen.apply(t)); + } + + currentIndexContainer = index.toIndexed("idx-", (n, o) -> persistence.write(o, Obj.class)); + + var idx = currentIndexContainer.indexForRead(persistence, OBJ_REF_SERIALIZER); + soft.assertThat(IntStream.range(0, table + additionsPerIteration)) + .allMatch(t -> objIdGen.apply(t).equals(idx.get(keyGen.apply(t)))); + } + } + + static Stream bigIndex() { + return Stream.of(arguments(3, 10), arguments(50, 250)); + } + + @Test + public void removeExistsInReference() { + var foo = key("foo"); + var bar = key("bar"); + var baz = key("baz"); + var id1 = randomObjId(); + var id2 = randomObjId(); + var id3 = randomObjId(); + var ref = Map.of(foo, id1, bar, id2, baz, id3); + + var updatable = + updatableIndexForTest(Map.of(foo, id1, bar, id2, baz, id3), Map.of(), OBJ_REF_SERIALIZER); + + soft.assertThat(updatable.embedded.asKeyList()).isEmpty(); + + soft.assertThat(updatable.asKeyList()).containsExactly(bar, baz, foo); + soft.assertThat(updatable) + .containsExactly(Map.entry(bar, id2), Map.entry(baz, id3), Map.entry(foo, id1)); + + soft.assertThat(updatable.remove(baz)).isTrue(); + + soft.assertThat(updatable.asKeyList()).containsExactly(bar, foo); + + soft.assertThat(updatable.embedded.asKeyList().size()).isEqualTo(1); + soft.assertThat(updatable.reference.asKeyList()).containsExactly(bar, baz, foo); + + soft.assertThat(updatable.embedded.asKeyList()).containsExactly(baz); + soft.assertThat(updatable.embedded.getElement(baz)) + .isNotNull() + .extracting(IndexElement::getValue) + .isNull(); + soft.assertThat(updatable.reference.getElement(baz)) + .extracting(IndexElement::getKey, IndexElement::getValue) + .containsExactly(baz, id3); + + // re-serialize + + var indexed = updatable.toIndexed("idx-", (name, obj) -> soft.fail("Unexpected obj persist")); + var deserialized = + (UpdatableIndexImpl) indexed.asUpdatableIndex(persistence, OBJ_REF_SERIALIZER); + + soft.assertThat(deserialized.embedded.asKeyList()).containsExactly(baz); + soft.assertThat(deserialized.embedded.getElement(baz)) + .isNotNull() + .extracting(IndexElement::getValue) + .isNull(); + soft.assertThat(deserialized.reference.asKeyList()).containsExactly(bar, baz, foo); + } + + @Test + public void removeExistsInReferenceAndUpdates() { + var foo = key("foo"); + var bar = key("bar"); + var baz = key("baz"); + var id1 = randomObjId(); + var id2 = randomObjId(); + var id3 = randomObjId(); + var id4 = randomObjId(); + + var updatable = + updatableIndexForTest( + Map.of(foo, id1, bar, id2, baz, id3), Map.of(baz, id4), OBJ_REF_SERIALIZER); + + soft.assertThat(updatable.asKeyList()).containsExactlyElementsOf(List.of(bar, baz, foo)); + soft.assertThat(updatable) + .containsExactly(indexElement(bar, id2), indexElement(baz, id4), indexElement(foo, id1)); + soft.assertThat(updatable.reference) + .containsExactly(indexElement(bar, id2), indexElement(baz, id3), indexElement(foo, id1)); + soft.assertThat(updatable.embedded).containsExactly(indexElement(baz, id4)); + + soft.assertThat(updatable.remove(baz)).isTrue(); + + soft.assertThat(updatable.asKeyList()).containsExactly(bar, foo); + + soft.assertThat(updatable.reference.asKeyList()).containsExactly(bar, baz, foo); + + soft.assertThat(updatable.embedded.asKeyList()).containsExactly(baz); + soft.assertThat(updatable.embedded.getElement(baz)) + .isNotNull() + .extracting(IndexElement::getValue) + .isNull(); + soft.assertThat(updatable.reference.getElement(baz)) + .extracting(IndexElement::getKey, IndexElement::getValue) + .containsExactly(baz, id3); + + // re-serialize + + var indexed = updatable.toIndexed("idx-", (name, obj) -> soft.fail("Unexpected obj persist")); + var deserialized = + (UpdatableIndexImpl) indexed.asUpdatableIndex(persistence, OBJ_REF_SERIALIZER); + + soft.assertThat(deserialized.embedded.asKeyList()).containsExactly(baz); + soft.assertThat(deserialized.embedded.getElement(baz)) + .isNotNull() + .extracting(IndexElement::getValue) + .isNull(); + soft.assertThat(deserialized.reference.asKeyList()).containsExactly(bar, baz, foo); + } + + @Test + public void spillOut() { + var updatable = updatableIndexForTest(Map.of(), Map.of(), OBJ_TEST_SERIALIZER); + var value1kB = ObjTestValue.objTestValueOfSize(1024); + var numValues = persistence.params().maxIndexStripeSize().asLong() / 1024 * 5; + + for (int i = 0; i < numValues; i++) { + updatable.put(key("k" + i), value1kB); + } + + var keyList = updatable.asKeyList(); + + var toPersist = new ArrayList>(); + var indexed = updatable.toIndexed("idx-", (n, o) -> toPersist.add(Map.entry(n, o))); + soft.assertThat(toPersist).hasSize(6); + + toPersist.stream().map(Map.Entry::getValue).forEach(o -> persistence.write(o, Obj.class)); + + var deserialized = indexed.indexForRead(persistence, OBJ_TEST_SERIALIZER); + soft.assertThat(Streams.stream(deserialized).map(Map.Entry::getKey)) + .containsExactlyElementsOf(keyList); + + var fromIndexed = indexed.asUpdatableIndex(persistence, OBJ_TEST_SERIALIZER); + soft.assertThat(Streams.stream(fromIndexed).map(Map.Entry::getKey)) + .containsExactlyElementsOf(keyList); + + indexed = + fromIndexed.toIndexed("idx-", (n, o) -> soft.fail("Unexpected obj persist %s / %s", n, o)); + + // add more + + updatable = + (UpdatableIndexImpl) + indexed.asUpdatableIndex(persistence, OBJ_TEST_SERIALIZER); + for (int i = 0; i < numValues; i++) { + updatable.put(key("k" + i + "b"), value1kB); + } + var keyList2 = updatable.asKeyList(); + soft.assertThat(keyList2).hasSize((int) numValues * 2); + + toPersist.clear(); + indexed = updatable.toIndexed("idx-", (n, o) -> toPersist.add(Map.entry(n, o))); + soft.assertThat(toPersist).hasSize(12); + toPersist.stream().map(Map.Entry::getValue).forEach(o -> persistence.write(o, Obj.class)); + + deserialized = indexed.indexForRead(persistence, OBJ_TEST_SERIALIZER); + soft.assertThat(Streams.stream(deserialized).map(Map.Entry::getKey)) + .containsExactlyElementsOf(keyList2); + + fromIndexed = indexed.asUpdatableIndex(persistence, OBJ_TEST_SERIALIZER); + soft.assertThat(Streams.stream(fromIndexed).map(Map.Entry::getKey)) + .containsExactlyElementsOf(keyList2); + + indexed = + fromIndexed.toIndexed("idx-", (n, o) -> soft.fail("Unexpected obj persist %s / %s", n, o)); + + // check that empty splits are removed + + updatable = + (UpdatableIndexImpl) + indexed.asUpdatableIndex(persistence, OBJ_TEST_SERIALIZER); + + var stripeToEmpty = indexed.stripes().get(1); + var stripeObj = + deserializeStoreIndex( + requireNonNull(persistence.fetch(stripeToEmpty.segment(), IndexStripeObj.class)) + .index(), + OBJ_TEST_SERIALIZER); + stripeObj.asKeyList().forEach(updatable::remove); + + // Index did NOT spill-out yet, the removes are in the embedded index, shadowing the reference + // index + var indexed2 = + updatable.toIndexed("idx-", (n, o) -> soft.fail("Unexpected obj persist %s / %s", n, o)); + soft.assertThat(indexed2.stripes()).containsExactlyElementsOf(indexed.stripes()); + var deserializedRemoved = + (UpdatableIndexImpl) + indexed2.asUpdatableIndex(persistence, OBJ_TEST_SERIALIZER); + // Index-API functions on 'StoreIndex' do not expose the removed keys + soft.assertThat(stripeObj.asKeyList()) + .allMatch(k -> deserializedRemoved.get(k) == null) + .allMatch(k -> !deserializedRemoved.contains(k)) + // verify that the remove-sentinel is still present + .allMatch(deserializedRemoved::containsElement, "containsElement(k)") + .allMatch( + k -> { + var el = deserializedRemoved.getElement(k); + return el != null && el.getValue() == null; + }, + "getElement(k)"); + + // Force spill-out (otherwise the above removes will just be carried over in the embedded index) + updatable = + (UpdatableIndexImpl) + indexed2.asUpdatableIndex(persistence, OBJ_TEST_SERIALIZER); + for (var i = 0; i < numValues / 5; i++) { + var k = key("sp1_" + i); + updatable.put(k, value1kB); + } + + toPersist.clear(); + indexed2 = updatable.toIndexed("idx-", (n, o) -> toPersist.add(Map.entry(n, o))); + soft.assertThat(toPersist).hasSizeGreaterThanOrEqualTo(1); + toPersist.stream().map(Map.Entry::getValue).forEach(o -> persistence.write(o, Obj.class)); + + // Verify that the whole stripe with the keys removed above is no longer part of the index + soft.assertThat(indexed2.stripes()).doesNotContain(stripeToEmpty); + var deserializedRemovedSpilled = + (UpdatableIndexImpl) + indexed2.asUpdatableIndex(persistence, OBJ_TEST_SERIALIZER); + soft.assertThat(stripeObj.asKeyList()) + .allMatch(k -> deserializedRemovedSpilled.get(k) == null, "get(k)") + .allMatch(k -> !deserializedRemovedSpilled.contains(k), "contains(k)") + // verify that the element, even the remove-sentinel, has been removed + .allMatch(k -> !deserializedRemovedSpilled.containsElement(k), "containsElement(k)") + .allMatch(k -> deserializedRemovedSpilled.getElement(k) == null, "getElement(k)"); + } + + UpdatableIndexImpl updatableIndexForTest( + List> referenceContents, + List> embeddedContents, + IndexValueSerializer serializer) { + var embedded = newStoreIndex(serializer); + embeddedContents.forEach(embedded::add); + + var indexContainerBuilder = ImmutableIndexContainer.builder().embedded(embedded.serialize()); + + if (!referenceContents.isEmpty()) { + var reference = newStoreIndex(serializer); + referenceContents.forEach(reference::add); + var stripeObj = + persistence.write( + IndexStripeObj.indexStripeObj(persistence.generateId(), reference.serialize()), + IndexStripeObj.class); + indexContainerBuilder.addStripe( + IndexStripe.indexStripe(reference.first(), reference.last(), objRef(stripeObj))); + } + + return (UpdatableIndexImpl) + indexContainerBuilder.build().asUpdatableIndex(persistence, serializer); + } + + UpdatableIndexImpl updatableIndexForTest( + Map referenceContents, + Map embeddedContents, + IndexValueSerializer serializer) { + return updatableIndexForTest( + referenceContents.entrySet().stream() + .map(e -> indexElement(e.getKey(), e.getValue())) + .toList(), + embeddedContents.entrySet().stream() + .map(e -> indexElement(e.getKey(), e.getValue())) + .toList(), + serializer); + } +} diff --git a/persistence/nosql/persistence/impl/src/test/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType b/persistence/nosql/persistence/impl/src/test/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType new file mode 100644 index 0000000000..773e61bdda --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType @@ -0,0 +1,23 @@ +# +# 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.impl.cache.DefaultCachingObj$DefaultCachingObjType +org.apache.polaris.persistence.nosql.impl.cache.DynamicCachingObj$DynamicCachingObjType +org.apache.polaris.persistence.nosql.impl.cache.NegativeCachingObj$NegativeCachingObjType +org.apache.polaris.persistence.nosql.impl.cache.NonCachingObj$NonCachingObjType diff --git a/persistence/nosql/persistence/impl/src/test/resources/logback-test.xml b/persistence/nosql/persistence/impl/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..fb74fc2c54 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/test/resources/logback-test.xml @@ -0,0 +1,30 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/AbstractPersistenceTests.java b/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/AbstractPersistenceTests.java new file mode 100644 index 0000000000..f855a624e2 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/AbstractPersistenceTests.java @@ -0,0 +1,844 @@ +/* + * 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.impl; + +import static java.util.Objects.requireNonNull; +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.assertj.core.api.InstanceOfAssertFactories.BYTE_ARRAY; +import static org.assertj.core.api.InstanceOfAssertFactories.LONG; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.polaris.persistence.nosql.api.Persistence; +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.exceptions.ReferenceAlreadyExistsException; +import org.apache.polaris.persistence.nosql.api.exceptions.ReferenceNotFoundException; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.AnotherTestObj; +import org.apache.polaris.persistence.nosql.api.obj.ImmutableVersionedTestObj; +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.SimpleTestObj; +import org.apache.polaris.persistence.nosql.api.obj.VersionedTestObj; +import org.apache.polaris.persistence.nosql.api.ref.ImmutableReference; +import org.apache.polaris.persistence.nosql.api.ref.Reference; +import org.apache.polaris.persistence.nosql.testextension.PersistenceTestExtension; +import org.apache.polaris.persistence.nosql.testextension.PolarisPersistence; +import org.assertj.core.api.Assumptions; +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.TestInfo; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junitpioneer.jupiter.cartesian.CartesianTest; +import org.junitpioneer.jupiter.cartesian.CartesianTest.Values; + +@ExtendWith({PersistenceTestExtension.class, SoftAssertionsExtension.class}) +public abstract class AbstractPersistenceTests { + @InjectSoftAssertions protected SoftAssertions soft; + + @PolarisPersistence protected Backend backend; + + protected abstract Persistence persistence(); + + @Test + public void referenceLifecycle(TestInfo testInfo) { + var refName = testInfo.getTestMethod().orElseThrow().getName(); + soft.assertThatThrownBy(() -> persistence().fetchReference(refName)) + .isInstanceOf(ReferenceNotFoundException.class) + .hasMessage(refName); + soft.assertThatThrownBy( + () -> + persistence() + .updateReferencePointer( + Reference.builder() + .name(refName) + .createdAtMicros(123L) + .previousPointers() + .build(), + objRef("type", 123L, 1))) + .isInstanceOf(ReferenceNotFoundException.class) + .hasMessage(refName); + + persistence().createReference(refName, Optional.empty()); + soft.assertThatThrownBy(() -> persistence().createReference(refName, Optional.empty())) + .isInstanceOf(ReferenceAlreadyExistsException.class) + .hasMessage(refName); + } + + @Test + public void referenceWithInitialPointer(TestInfo testInfo) { + var refName = testInfo.getTestMethod().orElseThrow().getName(); + var id1 = objRef(SimpleTestObj.TYPE, persistence().generateId(), 1); + var id2 = objRef(SimpleTestObj.TYPE, persistence().generateId(), 1); + + var ref1 = persistence().createReference(refName, Optional.of(id1)); + soft.assertThat(persistence().fetchReference(refName)).isEqualTo(ref1); + + soft.assertThatIllegalArgumentException() + .isThrownBy(() -> persistence().updateReferencePointer(ref1, id1)) + .withMessage("New pointer must not be equal to the expected pointer."); + + var ref2 = persistence().updateReferencePointer(ref1, id2); + soft.assertThat(ref2) + .get() + .isEqualTo( + ImmutableReference.builder() + .from(ref1) + .pointer(id2) + .previousPointers(id1.id()) + .build()); + soft.assertThat(persistence().fetchReference(refName)).isEqualTo(ref2.orElseThrow()); + } + + @Test + public void referenceWithoutInitialPointer(TestInfo testInfo) { + var refName = testInfo.getTestMethod().orElseThrow().getName(); + var id1 = objRef(SimpleTestObj.TYPE, persistence().generateId(), 1); + var id2 = objRef(SimpleTestObj.TYPE, persistence().generateId(), 1); + + var ref1 = persistence().createReference(refName, Optional.empty()); + soft.assertThat(persistence().fetchReference(refName)).isEqualTo(ref1); + + var ref2 = persistence().updateReferencePointer(ref1, id1); + soft.assertThat(ref2) + .get() + .isEqualTo(ImmutableReference.builder().from(ref1).pointer(id1).build()); + soft.assertThat(persistence().fetchReference(refName)).isEqualTo(ref2.orElseThrow()); + + var ref3 = persistence().updateReferencePointer(ref2.orElseThrow(), id2); + soft.assertThat(ref3) + .get() + .isEqualTo( + ImmutableReference.builder() + .from(ref1) + .pointer(id2) + .previousPointers(id1.id()) + .build()); + soft.assertThat(persistence().fetchReference(refName)).isEqualTo(ref3.orElseThrow()); + } + + @CartesianTest + public void createReferencesSilent( + @Values(ints = {0, 1, 2}) int numExisting, @Values(ints = {1, 2, 3, 5, 10}) int numRefs) { + var refNamePrefix = "createReferencesSilent"; + + var existingRefNames = + IntStream.range(0, numExisting).mapToObj(i -> refNamePrefix + "_ex_" + i).toList(); + + var existingRefs = new ArrayList(); + for (var refName : existingRefNames) { + var id = objRef(SimpleTestObj.TYPE, persistence().generateId(), 1); + var ref = persistence().createReference(refName, Optional.of(id)); + existingRefs.add(ref); + } + + soft.assertThat(existingRefNames).allSatisfy(refName -> persistence().fetchReference(refName)); + + var allRefNames = + IntStream.range(numExisting, numRefs) + .mapToObj(i -> refNamePrefix + "_all_" + i) + .collect(Collectors.toSet()); + + persistence().createReferencesSilent(allRefNames); + + for (var existingRef : existingRefs) { + var ref = persistence().fetchReference(existingRef.name()); + soft.assertThat(ref).describedAs(existingRef.name()).isEqualTo(existingRef); + } + + var updatedRefs = new ArrayList(); + for (var refName : allRefNames) { + var ref = persistence().fetchReference(refName); + var id = objRef(SimpleTestObj.TYPE, persistence().generateId(), 1); + ref = persistence().updateReferencePointer(ref, id).orElseThrow(); + updatedRefs.add(ref); + } + + persistence().createReferencesSilent(allRefNames); + + for (var updatedRef : updatedRefs) { + var ref = persistence().fetchReference(updatedRef.name()); + soft.assertThat(ref).describedAs(updatedRef.name()).isEqualTo(updatedRef); + } + } + + @Test + public void referenceRecentPointers(TestInfo testInfo) { + var type = new AbstractObjType<>("dummyTest", "dummy", Obj.class) {}; + var refName = testInfo.getTestMethod().orElseThrow().getName(); + var id1 = objRef(type, persistence().generateId(), 1); + var ref = persistence().createReference(refName, Optional.of(id1)); + + var recentPointers = new ArrayList(); + for (var i = 0; i < persistence().params().referencePreviousHeadCount(); i++) { + recentPointers.addFirst(ref.pointer().orElseThrow().id()); + var id = objRef(type, persistence().generateId(), 1); + ref = persistence().updateReferencePointer(ref, id).orElseThrow(); + soft.assertThat(ref) + .extracting(Reference::pointer, Reference::previousPointers) + .containsExactly( + Optional.of(id), recentPointers.stream().mapToLong(Long::longValue).toArray()); + } + + for (var i = 0; i < persistence().params().referencePreviousHeadCount(); i++) { + recentPointers.removeLast(); + recentPointers.addFirst(ref.pointer().orElseThrow().id()); + var id = objRef(type, persistence().generateId(), 1); + ref = persistence().updateReferencePointer(ref, id).orElseThrow(); + soft.assertThat(ref) + .extracting(Reference::pointer, Reference::previousPointers) + .containsExactly( + Optional.of(id), recentPointers.stream().mapToLong(Long::longValue).toArray()); + } + } + + /** + * Exercises a bunch of reference names that can be problematic if the database uses collators, + * that for example, collapse adjacent spaces. + */ + @Test + public void referenceNames() { + List refNames = + List.of( + // + "a-01", + "a-1", + "a-10", + "a-2", + "a-20", + "ä-01", + "ä-1", + "ä- 1", + "ä- 1", + // + "a01", + "a1", + "a10", + "a2", + "a20", + // + "a- 01", + "a- 1", + "a- 10", + "a- 2", + "a- 20", + // + "ä- 01", + "ä- 1", + "ä- 10", + "ä- 2", + "ä- 20", + // + "b- 01", + "b- 1", + "b- 10", + "b- 2", + "b- 20", + // + "a- 01", + "a- 1", + "a- 10", + "a- 2", + "a- 20"); + + var refToId = new HashMap(); + + for (String refName : refNames) { + var id = objRef(SimpleTestObj.TYPE, persistence().generateId(), 1); + soft.assertThatCode(() -> persistence().createReference(refName, Optional.of(id))) + .describedAs("create ref: %s", refName) + .doesNotThrowAnyException(); + refToId.put(refName, id); + } + + for (String refName : refNames) { + soft.assertThat(persistence().fetchReference(refName)) + .describedAs("fetch ref: %s", refName) + .extracting(Reference::pointer) + .isEqualTo(Optional.of(refToId.get(refName))); + } + } + + @Test + public void objs() { + var obj1 = + (SimpleTestObj) + SimpleTestObj.builder() + .id(persistence().generateId()) + .numParts(0) + .text("some text") + .build(); + var obj2 = + (SimpleTestObj) + SimpleTestObj.builder() + .id(persistence().generateId()) + .numParts(0) + .text("other text") + .build(); + var id1 = objRef(obj1); + var id2 = objRef(obj2); + soft.assertThat( + persistence().fetch(objRef(obj1.type().id(), obj1.id(), 1), SimpleTestObj.class)) + .isNull(); + obj1 = persistence().write(obj1, SimpleTestObj.class); + id1 = objRef(obj1); + obj2 = persistence().write(obj2, SimpleTestObj.class); + id2 = objRef(obj2); + soft.assertThat(obj1).extracting(Obj::createdAtMicros, LONG).isGreaterThan(0L); + soft.assertThat(obj2).extracting(Obj::createdAtMicros, LONG).isGreaterThan(0L); + soft.assertThat(obj1).extracting(Obj::numParts).isEqualTo(1); + soft.assertThat(obj2).extracting(Obj::numParts).isEqualTo(1); + var fetched1 = persistence().fetch(id1, SimpleTestObj.class); + soft.assertThat(fetched1) + .isEqualTo(obj1) + .extracting(Obj::createdAtMicros, LONG) + .isEqualTo(obj1.createdAtMicros()); + soft.assertThat(persistence().getImmediate(id1, SimpleTestObj.class)).isEqualTo(obj1); + // Check whether fetchMany() works with "0 expected parts" + soft.assertThat(persistence().fetch(objRef(id1.type(), id1.id(), 0), SimpleTestObj.class)) + .isEqualTo(obj1); + soft.assertThat( + persistence().getImmediate(objRef(id1.type(), id1.id(), 0), SimpleTestObj.class)) + .isEqualTo(obj1); + + var id1final = id1; + soft.assertThatIllegalArgumentException() + .isThrownBy( + () -> + persistence() + .fetch(objRef(id1final.type(), id1final.id(), 0), AnotherTestObj.class)) + .withMessageStartingWith( + "Mismatch between persisted object type 'test-simple' (interface org.apache.polaris.persistence.nosql.api.obj.SimpleTestObj) and deserialized interface org.apache.polaris.persistence.nosql.api.obj.AnotherTestObj."); + soft.assertThatIllegalArgumentException() + .isThrownBy( + () -> + persistence() + .fetch( + objRef(AnotherTestObj.TYPE.id(), id1final.id(), 0), AnotherTestObj.class)) + .withMessageStartingWith( + "Mismatch between persisted object type 'test-simple' (interface org.apache.polaris.persistence.nosql.api.obj.SimpleTestObj) and deserialized interface org.apache.polaris.persistence.nosql.api.obj.AnotherTestObj."); + soft.assertThatIllegalArgumentException() + .isThrownBy( + () -> + persistence() + .fetchMany(AnotherTestObj.class, objRef(id1final.type(), id1final.id(), 0))) + .withMessageStartingWith( + "Mismatch between persisted object type 'test-simple' (interface org.apache.polaris.persistence.nosql.api.obj.SimpleTestObj) and deserialized interface org.apache.polaris.persistence.nosql.api.obj.AnotherTestObj."); + soft.assertThatIllegalArgumentException() + .isThrownBy( + () -> + persistence() + .fetchMany( + AnotherTestObj.class, objRef(AnotherTestObj.TYPE.id(), id1final.id(), 0))) + .withMessageStartingWith( + "Mismatch between persisted object type 'test-simple' (interface org.apache.polaris.persistence.nosql.api.obj.SimpleTestObj) and deserialized interface org.apache.polaris.persistence.nosql.api.obj.AnotherTestObj."); + + var fetched2 = persistence().fetch(id2, SimpleTestObj.class); + soft.assertThat(fetched2) + .isEqualTo(obj2) + .extracting(Obj::createdAtMicros, LONG) + .isEqualTo(obj2.createdAtMicros()); + soft.assertThat(persistence().fetchMany(SimpleTestObj.class, id1, id2)) + .containsExactly(obj1, obj2); + soft.assertThat(persistence().fetchMany(SimpleTestObj.class, id1, null, id2)) + .containsExactly(obj1, null, obj2); + // Check whether fetchMany() works with "0 expected parts" + soft.assertThat( + persistence() + .fetchMany( + SimpleTestObj.class, + objRef(id1.type(), id1.id(), 0), + null, + objRef(id2.type(), id2.id(), 0))) + .containsExactly(obj1, null, obj2); + soft.assertThat( + persistence() + .fetchMany( + SimpleTestObj.class, + id1, + null, + id2, + objRef(SimpleTestObj.TYPE, persistence().generateId(), 1))) + .containsExactly(obj1, null, obj2, null); + + var obj1updated = + SimpleTestObj.builder().from(obj1).text("some other text").number(123).build(); + var obj2updated = SimpleTestObj.builder().from(obj2).text("different text").number(456).build(); + soft.assertThat(persistence().write(obj1updated, SimpleTestObj.class)) + .extracting(Obj::createdAtMicros, LONG) + .isGreaterThanOrEqualTo(obj1.createdAtMicros()); + soft.assertThat(persistence().write(obj2updated, SimpleTestObj.class)) + .extracting(Obj::createdAtMicros, LONG) + .isGreaterThanOrEqualTo(obj1.createdAtMicros()); + soft.assertThat(persistence().fetch(id1, SimpleTestObj.class)).isEqualTo(obj1updated); + soft.assertThat(persistence().fetch(id2, SimpleTestObj.class)).isEqualTo(obj2updated); + soft.assertThat(persistence().fetchMany(SimpleTestObj.class, id1, id2)) + .containsExactly(obj1updated, obj2updated); + + persistence().delete(objRef(SimpleTestObj.TYPE, persistence().generateId(), 1)); + persistence() + .deleteMany( + objRef(SimpleTestObj.TYPE, persistence().generateId(), 1), + objRef(SimpleTestObj.TYPE, persistence().generateId(), 1), + null); + soft.assertThat(persistence().fetchMany(SimpleTestObj.class, id1, id2)) + .containsExactly(obj1updated, obj2updated); + + persistence().delete(id1); + soft.assertThat(persistence().fetchMany(SimpleTestObj.class, id1, id2)) + .containsExactly(null, obj2updated); + persistence().delete(id2); + soft.assertThat(persistence().fetchMany(SimpleTestObj.class, id1, id2)) + .containsExactly(null, null); + + var obj1updated2 = SimpleTestObj.builder().from(obj1updated).optional("optional2").build(); + var obj2updated2 = SimpleTestObj.builder().from(obj2updated).optional("optional2").build(); + soft.assertThat(persistence().writeMany(SimpleTestObj.class, obj1updated2, obj2updated2)) + .containsExactly(obj1updated2, obj2updated2); + soft.assertThat(persistence().fetchMany(SimpleTestObj.class, id1, id2)) + .containsExactly(obj1updated2, obj2updated2); + } + + @ParameterizedTest + @ValueSource(ints = {50, 10 * 1024, 200 * 1024, 400 * 1024, 1024 * 1024, 13 * 1024 * 1024}) + public void hugeObject(int binaryLen) { + var data = new byte[binaryLen]; + ThreadLocalRandom.current().nextBytes(data); + + var obj = + (SimpleTestObj) + SimpleTestObj.builder().id(persistence().generateId()).numParts(0).binary(data).build(); + obj = persistence().write(obj, SimpleTestObj.class); + soft.assertThat(persistence().fetch(objRef(obj), SimpleTestObj.class)) + .isEqualTo(obj) + .extracting(SimpleTestObj::binary, BYTE_ARRAY) + .containsExactly(data); + var updatedObj = + (SimpleTestObj) SimpleTestObj.builder().from(obj).optional("optional2").build(); + persistence().write(updatedObj, SimpleTestObj.class); + soft.assertThat(persistence().fetch(objRef(obj), SimpleTestObj.class)) + .isEqualTo(updatedObj) + .extracting(SimpleTestObj::binary, BYTE_ARRAY) + .containsExactly(data); + + // Fetch with the "wrong" number of parts + soft.assertThat(persistence().fetch(objRef(obj.type(), obj.id(), 0), SimpleTestObj.class)) + .isEqualTo(updatedObj) + .extracting(SimpleTestObj::binary, BYTE_ARRAY) + .containsExactly(data); + soft.assertThat(persistence().fetch(objRef(obj.type(), obj.id(), 1), SimpleTestObj.class)) + .isEqualTo(updatedObj) + .extracting(SimpleTestObj::binary, BYTE_ARRAY) + .containsExactly(data); + soft.assertThat(persistence().fetch(objRef(obj.type(), obj.id(), 30), SimpleTestObj.class)) + .isEqualTo(updatedObj) + .extracting(SimpleTestObj::binary, BYTE_ARRAY) + .containsExactly(data); + soft.assertThat( + persistence().getImmediate(objRef(obj.type(), obj.id(), 0), SimpleTestObj.class)) + .isEqualTo(updatedObj) + .extracting(SimpleTestObj::binary, BYTE_ARRAY) + .containsExactly(data); + soft.assertThat( + persistence().getImmediate(objRef(obj.type(), obj.id(), 1), SimpleTestObj.class)) + .isEqualTo(updatedObj) + .extracting(SimpleTestObj::binary, BYTE_ARRAY) + .containsExactly(data); + soft.assertThat( + persistence().getImmediate(objRef(obj.type(), obj.id(), 30), SimpleTestObj.class)) + .isEqualTo(updatedObj) + .extracting(SimpleTestObj::binary, BYTE_ARRAY) + .containsExactly(data); + soft.assertThat(persistence().fetchMany(SimpleTestObj.class, objRef(obj.type(), obj.id(), 0))) + .containsExactly(updatedObj); + soft.assertThat(persistence().fetchMany(SimpleTestObj.class, objRef(obj.type(), obj.id(), 1))) + .containsExactly(updatedObj); + soft.assertThat(persistence().fetchMany(SimpleTestObj.class, objRef(obj.type(), obj.id(), 30))) + .containsExactly(updatedObj); + } + + @Test + public void conditionalObjects() { + var nonVersionedObj1 = + SimpleTestObj.builder() + .id(persistence().generateId()) + .numParts(0) + .text("some text") + .build(); + soft.assertThatIllegalArgumentException() + .isThrownBy(() -> persistence().conditionalInsert(nonVersionedObj1, SimpleTestObj.class)) + .withMessage("'obj' must have a non-null 'versionToken'"); + + var obj1initial = + VersionedTestObj.builder() + .id(persistence().generateId()) + .versionToken("t1") + .someValue("foo") + .build(); + var obj2initial = + VersionedTestObj.builder() + .id(persistence().generateId()) + .versionToken("t2") + .someValue("bar") + .build(); + var objNotPresent = + VersionedTestObj.builder() + .id(persistence().generateId()) + .versionToken("t3") + .someValue("baz") + .build(); + + var obj1 = persistence().conditionalInsert(obj1initial, VersionedTestObj.class); + soft.assertThat(obj1) + .isEqualTo(ImmutableVersionedTestObj.builder().from(obj1initial).build()) + .extracting(Obj::createdAtMicros, LONG) + .isGreaterThan(0L); + var obj2 = persistence().conditionalInsert(obj2initial, VersionedTestObj.class); + soft.assertThat(obj2) + .isEqualTo(ImmutableVersionedTestObj.builder().from(obj2initial).build()) + .extracting(Obj::createdAtMicros, LONG) + .isGreaterThan(0L); + + // Make IDEs happy + requireNonNull(obj1); + requireNonNull(obj2); + + soft.assertThatIllegalArgumentException() + .isThrownBy( + () -> + persistence() + .conditionalInsert( + (VersionedTestObj) obj1.withNumParts(0), VersionedTestObj.class)) + .withMessage("'obj' must have 'numParts' == 1"); + + soft.assertThat( + persistence() + .conditionalInsert( + ImmutableVersionedTestObj.builder().from(obj1).build(), VersionedTestObj.class)) + .isNull(); + soft.assertThat( + persistence() + .conditionalInsert( + ImmutableVersionedTestObj.builder().from(obj2).build(), VersionedTestObj.class)) + .isNull(); + + soft.assertThat(persistence().fetch(objRef(obj1), VersionedTestObj.class)).isEqualTo(obj1); + soft.assertThat(persistence().fetch(objRef(obj2), VersionedTestObj.class)).isEqualTo(obj2); + + var obj1updated = + VersionedTestObj.builder() + .from(obj1) + .someValue("updated foo") + .versionToken("t1updated") + .build(); + var obj2updated = + VersionedTestObj.builder() + .from(obj2) + .someValue("updated bar") + .versionToken("t2updated") + .build(); + + soft.assertThat( + persistence() + .conditionalUpdate( + VersionedTestObj.builder().from(obj1).versionToken("incorrect").build(), + obj1updated, + VersionedTestObj.class)) + .isNull(); + soft.assertThat(persistence().conditionalUpdate(obj1updated, obj1, VersionedTestObj.class)) + .isNull(); + soft.assertThatIllegalArgumentException() + .isThrownBy( + () -> persistence().conditionalUpdate(obj1updated, obj1updated, VersionedTestObj.class)) + .withMessage("'versionToken' of 'expected' and 'update' must not be equal"); + + soft.assertThat(persistence().fetch(objRef(obj1), VersionedTestObj.class)).isEqualTo(obj1); + + var updated1 = persistence().conditionalUpdate(obj1, obj1updated, VersionedTestObj.class); + soft.assertThat(updated1) + .isEqualTo(obj1updated) + .extracting(Obj::createdAtMicros, LONG) + .isGreaterThan(0L); + var updated2 = persistence().conditionalUpdate(obj2, obj2updated, VersionedTestObj.class); + soft.assertThat(updated2) + .isEqualTo(obj2updated) + .extracting(Obj::createdAtMicros, LONG) + .isGreaterThan(0L); + soft.assertThat( + persistence() + .conditionalUpdate( + ImmutableVersionedTestObj.builder().from(objNotPresent).build(), + VersionedTestObj.builder().from(objNotPresent).versionToken("meep").build(), + VersionedTestObj.class)) + .isNull(); + + soft.assertThat(persistence().fetch(objRef(obj1), VersionedTestObj.class)) + .isEqualTo(obj1updated); + soft.assertThat(persistence().fetch(objRef(obj2), VersionedTestObj.class)) + .isEqualTo(obj2updated); + + soft.assertThat(persistence().conditionalDelete(obj1, VersionedTestObj.class)).isFalse(); + + soft.assertThat(persistence().fetch(objRef(obj1), VersionedTestObj.class)) + .isEqualTo(obj1updated); + + soft.assertThat(persistence().conditionalDelete(obj1updated, VersionedTestObj.class)).isTrue(); + soft.assertThat(persistence().conditionalDelete(obj1updated, VersionedTestObj.class)).isFalse(); + soft.assertThat( + persistence().conditionalDelete(objNotPresent.withNumParts(1), VersionedTestObj.class)) + .isFalse(); + + soft.assertThat(persistence().fetch(objRef(obj1), VersionedTestObj.class)).isNull(); + soft.assertThat(persistence().fetch(objRef(obj2), VersionedTestObj.class)) + .isEqualTo(obj2updated); + } + + @Test + public void backendRealmDeletion( + @PolarisPersistence Persistence one, + @PolarisPersistence Persistence two, + @PolarisPersistence Persistence three) { + Assumptions.assumeThat(backend.supportsRealmDeletion()).isTrue(); + soft.assertThat(one.realmId()) + .isNotEqualTo(two.realmId()) + .isNotEqualTo(persistence().realmId()); + + var num = 20; + + var oneObjs = new ArrayList(); + var twoObjs = new ArrayList(); + var threeObjs = new ArrayList(); + threeRealmsSetup(one, two, three, oneObjs, twoObjs, threeObjs, num); + + backend.deleteRealms(Set.of()); + + // No realm deleted, all created refs + objs must still exist + for (var i = 0; i < num; i++) { + var ref = "ref-" + i; + soft.assertThatCode(() -> one.fetchReference(ref)).doesNotThrowAnyException(); + soft.assertThatCode(() -> two.fetchReference(ref)).doesNotThrowAnyException(); + soft.assertThatCode(() -> three.fetchReference(ref)).doesNotThrowAnyException(); + } + soft.assertThat(one.fetchMany(SimpleTestObj.class, oneObjs.toArray(new ObjRef[0]))) + .doesNotContainNull() + .extracting(SimpleTestObj::number) + .extracting(Number::intValue) + .containsExactly(IntStream.range(0, num).boxed().toArray(Integer[]::new)); + soft.assertThat(two.fetchMany(SimpleTestObj.class, twoObjs.toArray(new ObjRef[0]))) + .doesNotContainNull() + .extracting(SimpleTestObj::number) + .extracting(Number::intValue) + .containsExactly(IntStream.range(0, num).boxed().toArray(Integer[]::new)); + soft.assertThat(three.fetchMany(SimpleTestObj.class, threeObjs.toArray(new ObjRef[0]))) + .doesNotContainNull() + .extracting(SimpleTestObj::number) + .extracting(Number::intValue) + .containsExactly(IntStream.range(0, num).boxed().toArray(Integer[]::new)); + + backend.deleteRealms(Set.of(one.realmId())); + + // realm 1 deleted + for (var i = 0; i < num; i++) { + var ref = "ref-" + i; + soft.assertThatThrownBy(() -> one.fetchReference(ref)) + .isInstanceOf(ReferenceNotFoundException.class); + soft.assertThatCode(() -> two.fetchReference(ref)).doesNotThrowAnyException(); + soft.assertThatCode(() -> three.fetchReference(ref)).doesNotThrowAnyException(); + } + soft.assertThat(one.fetchMany(SimpleTestObj.class, oneObjs.toArray(new ObjRef[0]))) + .hasSize(num) + .containsOnlyNulls(); + soft.assertThat(two.fetchMany(SimpleTestObj.class, twoObjs.toArray(new ObjRef[0]))) + .doesNotContainNull() + .extracting(SimpleTestObj::number) + .extracting(Number::intValue) + .containsExactly(IntStream.range(0, num).boxed().toArray(Integer[]::new)); + soft.assertThat(three.fetchMany(SimpleTestObj.class, threeObjs.toArray(new ObjRef[0]))) + .doesNotContainNull() + .extracting(SimpleTestObj::number) + .extracting(Number::intValue) + .containsExactly(IntStream.range(0, num).boxed().toArray(Integer[]::new)); + + backend.deleteRealms(Set.of(two.realmId(), three.realmId())); + + // realms 1+2+3 deleted + for (var i = 0; i < num; i++) { + var ref = "ref-" + i; + soft.assertThatThrownBy(() -> one.fetchReference(ref)) + .isInstanceOf(ReferenceNotFoundException.class); + soft.assertThatThrownBy(() -> two.fetchReference(ref)) + .isInstanceOf(ReferenceNotFoundException.class); + soft.assertThatThrownBy(() -> three.fetchReference(ref)) + .isInstanceOf(ReferenceNotFoundException.class); + } + soft.assertThat(one.fetchMany(SimpleTestObj.class, oneObjs.toArray(new ObjRef[0]))) + .hasSize(num) + .containsOnlyNulls(); + soft.assertThat(two.fetchMany(SimpleTestObj.class, twoObjs.toArray(new ObjRef[0]))) + .hasSize(num) + .containsOnlyNulls(); + soft.assertThat(three.fetchMany(SimpleTestObj.class, threeObjs.toArray(new ObjRef[0]))) + .hasSize(num) + .containsOnlyNulls(); + } + + @Test + public void backendScan( + @PolarisPersistence Persistence one, + @PolarisPersistence Persistence two, + @PolarisPersistence Persistence three) { + soft.assertThat(one.realmId()) + .isNotEqualTo(two.realmId()) + .isNotEqualTo(persistence().realmId()); + + var num = 20; + + var oneObjs = new ArrayList(); + var twoObjs = new ArrayList(); + var threeObjs = new ArrayList(); + threeRealmsSetup(one, two, three, oneObjs, twoObjs, threeObjs, num); + + var realmRefs = new HashMap>(); + var realmObjs = new HashMap>(); + backend.scanBackend( + (realm, ref, c) -> realmRefs.computeIfAbsent(realm, k -> new ArrayList<>()).add(ref), + (realm, type, persistId, c) -> + realmObjs + .computeIfAbsent(realm, k -> new ArrayList<>()) + .add(objRef(type, persistId.id(), 1))); + + soft.assertThat(realmRefs).containsKeys(one.realmId(), two.realmId(), three.realmId()); + soft.assertThat(realmObjs).containsKeys(one.realmId(), two.realmId(), three.realmId()); + + var refNames = IntStream.range(0, num).mapToObj(i -> "ref-" + i).toArray(String[]::new); + soft.assertThat(realmRefs.get(one.realmId())).containsExactlyInAnyOrder(refNames); + soft.assertThat(realmRefs.get(two.realmId())).containsExactlyInAnyOrder(refNames); + soft.assertThat(realmRefs.get(three.realmId())).containsExactlyInAnyOrder(refNames); + + soft.assertThat(realmObjs.get(one.realmId())).containsExactlyInAnyOrderElementsOf(oneObjs); + soft.assertThat(realmObjs.get(two.realmId())).containsExactlyInAnyOrderElementsOf(twoObjs); + soft.assertThat(realmObjs.get(three.realmId())).containsExactlyInAnyOrderElementsOf(threeObjs); + } + + @Test + public void backendBulkDeletions( + @PolarisPersistence Persistence one, + @PolarisPersistence Persistence two, + @PolarisPersistence Persistence three) { + soft.assertThat(one.realmId()) + .isNotEqualTo(two.realmId()) + .isNotEqualTo(persistence().realmId()); + + var num = 20; + + var oneObjs = new HashSet(); + var twoObjs = new HashSet(); + var threeObjs = new HashSet(); + threeRealmsSetup(one, two, three, oneObjs, twoObjs, threeObjs, num); + + var refNames = IntStream.range(0, num).mapToObj(i -> "ref-" + i).collect(Collectors.toSet()); + backend.batchDeleteRefs( + Map.of( + one.realmId(), refNames, + two.realmId(), refNames, + three.realmId(), refNames)); + backend.batchDeleteObjs( + Map.of( + one.realmId(), + oneObjs.stream() + .map(id -> PersistId.persistId(id.id(), 0)) + .collect(Collectors.toSet()), + two.realmId(), + twoObjs.stream() + .map(id -> PersistId.persistId(id.id(), 0)) + .collect(Collectors.toSet()), + three.realmId(), + threeObjs.stream() + .map(id -> PersistId.persistId(id.id(), 0)) + .collect(Collectors.toSet()))); + + for (var i = 0; i < num; i++) { + var ref = "ref-" + i; + soft.assertThatThrownBy(() -> one.fetchReference(ref)) + .describedAs("realm one: %s", ref) + .isInstanceOf(ReferenceNotFoundException.class); + soft.assertThatThrownBy(() -> two.fetchReference(ref)) + .describedAs("realm two: %s", ref) + .isInstanceOf(ReferenceNotFoundException.class); + soft.assertThatThrownBy(() -> three.fetchReference(ref)) + .describedAs("realm three: %s", ref) + .isInstanceOf(ReferenceNotFoundException.class); + } + soft.assertThat(one.fetchMany(SimpleTestObj.class, oneObjs.toArray(new ObjRef[0]))) + .hasSize(num) + .containsOnlyNulls(); + soft.assertThat(two.fetchMany(SimpleTestObj.class, twoObjs.toArray(new ObjRef[0]))) + .hasSize(num) + .containsOnlyNulls(); + soft.assertThat(three.fetchMany(SimpleTestObj.class, threeObjs.toArray(new ObjRef[0]))) + .hasSize(num) + .containsOnlyNulls(); + } + + private void threeRealmsSetup( + Persistence one, + Persistence two, + Persistence three, + Collection oneObjs, + Collection twoObjs, + Collection threeObjs, + int num) { + for (var i = 0; i < num; i++) { + one.createReference("ref-" + i, Optional.empty()); + two.createReference("ref-" + i, Optional.empty()); + three.createReference("ref-" + i, Optional.empty()); + + var o = + one.write( + SimpleTestObj.builder().id(one.generateId()).number(i).build(), SimpleTestObj.class); + soft.assertThat(o).isNotNull(); + oneObjs.add(objRef(o)); + o = + two.write( + SimpleTestObj.builder().id(two.generateId()).number(i).build(), SimpleTestObj.class); + soft.assertThat(o).isNotNull(); + twoObjs.add(objRef(o)); + o = + three.write( + SimpleTestObj.builder().id(three.generateId()).number(i).build(), + SimpleTestObj.class); + soft.assertThat(o).isNotNull(); + threeObjs.add(objRef(o)); + } + } +} diff --git a/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/commits/BaseTestCommitLogImpl.java b/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/commits/BaseTestCommitLogImpl.java new file mode 100644 index 0000000000..05c654eb65 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/commits/BaseTestCommitLogImpl.java @@ -0,0 +1,77 @@ +/* + * 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.impl.commits; + +import java.util.Collections; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.testextension.PersistenceTestExtension; +import org.apache.polaris.persistence.nosql.testextension.PolarisPersistence; +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.TestInfo; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@ExtendWith({PersistenceTestExtension.class, SoftAssertionsExtension.class}) +public abstract class BaseTestCommitLogImpl { + @InjectSoftAssertions protected SoftAssertions soft; + @PolarisPersistence protected Persistence persistence; + + @ParameterizedTest + @ValueSource(ints = {0, 1, 3, 19, 20, 21, 39, 40, 41, 255}) + public void commitLogs(int numCommits, TestInfo testInfo) throws Exception { + var refName = testInfo.getTestMethod().orElseThrow().getName(); + + persistence.createReference(refName, Optional.empty()); + + var committer = persistence.createCommitter(refName, SimpleCommitTestObj.class, String.class); + for (int i = 0; i < numCommits; i++) { + var payload = "commit #" + i; + committer.commit( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + return state.commitResult( + "foo", ImmutableSimpleCommitTestObj.builder().payload(payload), refObj); + }); + } + + // Commit log in "reversed" (most recent commit last) + var commits = persistence.commits(); + var expectedPayloads = + IntStream.range(0, numCommits).mapToObj(i -> "commit #" + i).collect(Collectors.toList()); + soft.assertThatIterator(commits.commitLogReversed(refName, 0L, SimpleCommitTestObj.class)) + .toIterable() + .extracting(SimpleCommitTestObj::payload) + .containsExactlyElementsOf(expectedPayloads); + + // Commit log in "natural" (most recent commit first) + Collections.reverse(expectedPayloads); + soft.assertThatIterator( + commits.commitLog(refName, OptionalLong.empty(), SimpleCommitTestObj.class)) + .toIterable() + .extracting(SimpleCommitTestObj::payload) + .containsExactlyElementsOf(expectedPayloads); + } +} diff --git a/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/commits/BaseTestCommitterImpl.java b/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/commits/BaseTestCommitterImpl.java new file mode 100644 index 0000000000..bb74c99a3c --- /dev/null +++ b/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/commits/BaseTestCommitterImpl.java @@ -0,0 +1,785 @@ +/* + * 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.impl.commits; + +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.commit.CommitException; +import org.apache.polaris.persistence.nosql.api.exceptions.ReferenceNotFoundException; +import org.apache.polaris.persistence.nosql.api.obj.AnotherTestObj; +import org.apache.polaris.persistence.nosql.api.obj.CommitTestObj; +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.VersionedTestObj; +import org.apache.polaris.persistence.nosql.testextension.PersistenceTestExtension; +import org.apache.polaris.persistence.nosql.testextension.PolarisPersistence; +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.TestInfo; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith({PersistenceTestExtension.class, SoftAssertionsExtension.class}) +public abstract class BaseTestCommitterImpl { + @InjectSoftAssertions protected SoftAssertions soft; + + @PolarisPersistence(fastRetries = true) + protected Persistence persistence; + + @Test + public void committerStateImpl() { + var state = new CommitterImpl.CommitterStateImpl(persistence); + var o1 = + CommitTestObj.builder() + .id(persistence.generateId()) + .text("simple 1") + .seq(1L) + .tail(new long[0]) + .build(); + var o2 = AnotherTestObj.builder().id(persistence.generateId()).text("another 2").build(); + var o1b = + VersionedTestObj.builder() + .from(o1) + .id(persistence.generateId()) + .someValue("another 1 b") + .build(); + + soft.assertThat(state.getWrittenByKey("one")).isNull(); + soft.assertThatCode(() -> state.writeIntent("one", o1)).doesNotThrowAnyException(); + soft.assertThat(state.idsUsed).containsExactly(objRef(o1)); + soft.assertThatIllegalStateException() + .isThrownBy(() -> state.writeIntent("one", o2)) + .withMessage("The object-key 'one' has already been used"); + soft.assertThat(state.deleteIds).isEmpty(); + soft.assertThat(state.idsUsed).containsExactly(objRef(o1)); + soft.assertThat(state.forAttempt).containsExactly(Map.entry(objRef(o1), o1)); + + soft.assertThatIllegalStateException() + .isThrownBy(() -> state.writeIntent("two", o1)) + .withMessageStartingWith("Object ID '") + .withMessageContaining("' to be persisted has already been used. "); + soft.assertThat(state.getWrittenByKey("two")).isNull(); + soft.assertThat(state.deleteIds).isEmpty(); + soft.assertThat(state.idsUsed).containsExactly(objRef(o1)); + soft.assertThat(state.forAttempt).containsExactly(Map.entry(objRef(o1), o1)); + + soft.assertThatIllegalStateException() + .isThrownBy(() -> state.writeIfNew("two", o1)) + .withMessageStartingWith("Object ID '") + .withMessageContaining("' to be persisted has already been used. "); + soft.assertThat(state.getWrittenByKey("two")).isNull(); + soft.assertThat(state.deleteIds).isEmpty(); + soft.assertThat(state.idsUsed).containsExactly(objRef(o1)); + soft.assertThat(state.forAttempt).containsExactly(Map.entry(objRef(o1), o1)); + + soft.assertThatIllegalStateException() + .isThrownBy(() -> state.writeOrReplace("two", o1)) + .withMessageStartingWith("Object ID '") + .withMessageContaining("' to be persisted has already been used. "); + soft.assertThat(state.getWrittenByKey("two")).isNull(); + soft.assertThat(state.deleteIds).isEmpty(); + soft.assertThat(state.idsUsed).containsExactly(objRef(o1)); + soft.assertThat(state.forAttempt).containsExactly(Map.entry(objRef(o1), o1)); + + soft.assertThat(state.getWrittenByKey("one")).isSameAs(o1); + soft.assertThat(state.writeIfNew("one", o1b)).isSameAs(o1); + soft.assertThat(state.deleteIds).isEmpty(); + soft.assertThat(state.idsUsed).containsExactly(objRef(o1)); + soft.assertThat(state.forAttempt).containsExactly(Map.entry(objRef(o1), o1)); + + soft.assertThat(state.getWrittenByKey("one")).isSameAs(o1); + soft.assertThat(state.writeOrReplace("one", o2)).isSameAs(o2); + soft.assertThat(state.deleteIds).containsExactly(objRef(o1)); + soft.assertThat(state.idsUsed).containsExactlyInAnyOrder(objRef(o1), objRef(o2)); + soft.assertThat(state.forAttempt).containsExactly(Map.entry(objRef(o2), o2)); + + soft.assertThat(state.getWrittenByKey("one")).isSameAs(o2); + soft.assertThat(state.writeOrReplace("one", o1b)).isSameAs(o1b); + soft.assertThat(state.deleteIds).containsExactlyInAnyOrder(objRef(o1), objRef(o2)); + soft.assertThat(state.idsUsed).containsExactlyInAnyOrder(objRef(o1), objRef(o2), objRef(o1b)); + soft.assertThat(state.forAttempt).containsExactly(Map.entry(objRef(o1b), o1b)); + } + + @Test + public void simpleCase(TestInfo testInfo) throws Exception { + var initialObj = + persistence.write( + CommitTestObj.builder() + .id(persistence.generateId()) + .text("initial") + .seq(1) + .tail(new long[0]) + .build(), + CommitTestObj.class); + var referenceName = testInfo.getTestMethod().orElseThrow().getName(); + + // Prepare, create reference with initial object + + persistence.write(initialObj, CommitTestObj.class); + persistence.createReference(referenceName, Optional.of(objRef(initialObj))); + + var committed = + persistence + .createCommitter(referenceName, CommitTestObj.class, String.class) + .commit( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + soft.assertThat(refObj).get().isEqualTo(initialObj).isNotSameAs(initialObj); + + // Commit attempt works here + return state.commitResult( + "foo", CommitTestObj.builder().text("result text"), refObj); + }); + + soft.assertThat(committed).contains("foo"); + var newHead = persistence.fetchReferenceHead(referenceName, CommitTestObj.class); + soft.assertThat(newHead) + .get() + .extracting(CommitTestObj::text, CommitTestObj::seq, CommitTestObj::tail) + .containsExactly("result text", 2L, new long[] {initialObj.id()}); + + var notCommitted = + persistence + .createCommitter(referenceName, CommitTestObj.class, String.class) + .commit( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + soft.assertThat(refObj).get().isNotEqualTo(initialObj); + + // Commit attempt works here + return state.noCommit(); + }); + soft.assertThat(notCommitted).isEmpty(); + + var checkHead = persistence.fetchReferenceHead(referenceName, CommitTestObj.class); + soft.assertThat(checkHead).isEqualTo(newHead); + + var notCommittedWithResult = + persistence + .createCommitter(referenceName, CommitTestObj.class, String.class) + .commit( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + soft.assertThat(refObj).get().isNotEqualTo(initialObj); + + // Commit attempt works here + return state.noCommit("not committed"); + }); + soft.assertThat(notCommittedWithResult).contains("not committed"); + + var checkHead2 = persistence.fetchReferenceHead(referenceName, CommitTestObj.class); + soft.assertThat(checkHead2).isEqualTo(newHead); + } + + @Test + @SuppressWarnings("ReturnValueIgnored") + public void nonExistingReferenceThrows(TestInfo testInfo) { + var referenceName = testInfo.getTestMethod().orElseThrow().getName(); + + soft.assertThatThrownBy( + () -> + persistence + .createCommitter(referenceName, CommitTestObj.class, String.class) + .commit( + (state, refObjSupplier) -> { + refObjSupplier.get(); + soft.fail("Must not be call"); + return Optional.of( + CommitTestObj.builder() + .id(persistence.generateId()) + .text("initial") + .build()); + })) + .isInstanceOf(ReferenceNotFoundException.class); + } + + @Test + public void simpleImmediatelySuccessfulCommit(TestInfo testInfo) throws Exception { + var initialObj = + persistence.write( + CommitTestObj.builder() + .id(persistence.generateId()) + .text("initial") + .seq(1) + .tail(new long[0]) + .build(), + CommitTestObj.class); + var referenceName = testInfo.getTestMethod().orElseThrow().getName(); + + var anotherObj1 = + AnotherTestObj.builder().id(persistence.generateId()).text("another 1").build(); + var anotherObj2 = + AnotherTestObj.builder().id(persistence.generateId()).text("another 2").build(); + + persistence.write(initialObj, CommitTestObj.class); + persistence.createReference(referenceName, Optional.of(objRef(initialObj))); + + soft.assertThat( + persistence.fetchMany( + Obj.class, + objRef(anotherObj1.withNumParts(1)), + objRef(anotherObj2.withNumParts(1)))) + .containsOnlyNulls(); + + soft.assertThat( + persistence + .createCommitter(referenceName, CommitTestObj.class, String.class) + .commit( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + soft.assertThat(refObj).get().isEqualTo(initialObj).isNotSameAs(initialObj); + soft.assertThat(state.writeIfNew("another 1", anotherObj1)) + .isSameAs(anotherObj1); + soft.assertThat(state.writeIfNew("another 2", anotherObj2)) + .isSameAs(anotherObj2); + return state.commitResult( + "foo", CommitTestObj.builder().text("result"), refObj); + })) + .contains("foo"); + + soft.assertThat( + persistence.fetchMany( + Obj.class, + objRef(anotherObj1.withNumParts(1)), + objRef(anotherObj2.withNumParts(1)))) + .containsExactly(anotherObj1.withNumParts(1), anotherObj2.withNumParts(1)); + } + + @Test + public void writeIntentSuccessfulCommitAfterFourRetries(TestInfo testInfo) throws Exception { + var initialObj = + persistence.write( + CommitTestObj.builder() + .id(persistence.generateId()) + .text("initial") + .seq(1) + .tail(new long[0]) + .build(), + CommitTestObj.class); + var referenceName = testInfo.getTestMethod().orElseThrow().getName(); + + var createdObjs = new ArrayList(); + var expectedObjs = new ArrayList(); + var iteration = new AtomicInteger(); + + persistence.write(initialObj, CommitTestObj.class); + persistence.createReference(referenceName, Optional.of(objRef(initialObj))); + + soft.assertThat( + persistence + .createCommitter(referenceName, CommitTestObj.class, String.class) + .commit( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + soft.assertThat(refObj).get().isEqualTo(initialObj).isNotSameAs(initialObj); + + var attempt = iteration.incrementAndGet(); + + if (attempt == 1) { + + var anotherObj1 = + AnotherTestObj.builder() + .id(persistence.generateId()) + .text("another 1, attempt " + attempt) + .build(); + var anotherObj2 = + AnotherTestObj.builder() + .id(persistence.generateId()) + .text("another 2, attempt " + attempt) + .build(); + + createdObjs.add(objRef(anotherObj1)); + createdObjs.add(objRef(anotherObj2)); + + soft.assertThat(state.getWrittenByKey("another 1")).isNull(); + soft.assertThat(state.getWrittenByKey("another 2")).isNull(); + + soft.assertThatCode(() -> state.writeIntent("another 1", anotherObj1)) + .doesNotThrowAnyException(); + soft.assertThatCode(() -> state.writeIntent("another 2", anotherObj2)) + .doesNotThrowAnyException(); + expectedObjs.add(objRef(anotherObj1)); + expectedObjs.add(objRef(anotherObj2)); + } else { + soft.assertThat(state.getWrittenByKey("another 1")).isNotNull(); + soft.assertThat(state.getWrittenByKey("another 2")).isNotNull(); + } + + if (attempt < 4) { + // retry + return Optional.empty(); + } + + var resultObj = CommitTestObj.builder().text("result"); + + var r = state.commitResult("foo", resultObj, refObj); + expectedObjs.add(objRef(r.orElseThrow())); + return r; + })) + .contains("foo"); + + soft.assertThat(expectedObjs).hasSize(3).doesNotHaveDuplicates(); + + // 4 attempts, 2 x 'AnotherTestObj' + soft.assertThat(createdObjs).hasSize(2).doesNotHaveDuplicates(); + + var unexpectedObjs = new HashSet<>(createdObjs); + expectedObjs.forEach(unexpectedObjs::remove); + soft.assertThat(unexpectedObjs).isEmpty(); + + soft.assertThat(persistence.fetchMany(Obj.class, withPartNum1(expectedObjs))) + .hasSize(3) + .doesNotContainNull(); + soft.assertThat(persistence.fetch(objRef(initialObj.withNumParts(1)), CommitTestObj.class)) + .isEqualTo(initialObj.withNumParts(1)); + } + + @Test + public void writeIfNewSuccessfulCommitAfterFourRetries(TestInfo testInfo) throws Exception { + var initialObj = + persistence.write( + CommitTestObj.builder() + .id(persistence.generateId()) + .text("initial") + .seq(1) + .tail(new long[0]) + .build(), + CommitTestObj.class); + var referenceName = testInfo.getTestMethod().orElseThrow().getName(); + + var createdObjs = new ArrayList(); + var expectedObjs = new ArrayList(); + var iteration = new AtomicInteger(); + + persistence.write(initialObj, CommitTestObj.class); + persistence.createReference(referenceName, Optional.of(objRef(initialObj))); + + soft.assertThat( + persistence + .createCommitter(referenceName, CommitTestObj.class, String.class) + .commit( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + soft.assertThat(refObj).get().isEqualTo(initialObj).isNotSameAs(initialObj); + + var attempt = iteration.incrementAndGet(); + + var anotherObj1 = + AnotherTestObj.builder() + .id(persistence.generateId()) + .text("another 1, attempt " + attempt) + .build(); + var anotherObj2 = + AnotherTestObj.builder() + .id(persistence.generateId()) + .text("another 2, attempt " + attempt) + .build(); + + createdObjs.add(objRef(anotherObj1)); + createdObjs.add(objRef(anotherObj2)); + + if (attempt == 1) { + soft.assertThat(state.getWrittenByKey("another 1")).isNull(); + soft.assertThat(state.getWrittenByKey("another 2")).isNull(); + + soft.assertThat(state.writeIfNew("another 1", anotherObj1)) + .isSameAs(anotherObj1); + soft.assertThat(state.writeIfNew("another 2", anotherObj2)) + .isSameAs(anotherObj2); + expectedObjs.add(objRef(anotherObj1)); + expectedObjs.add(objRef(anotherObj2)); + } else { + soft.assertThat(state.getWrittenByKey("another 1")).isNotNull(); + soft.assertThat(state.getWrittenByKey("another 2")).isNotNull(); + + soft.assertThat(state.writeIfNew("another 1", anotherObj1)) + .isNotEqualTo(anotherObj1); + soft.assertThat(state.writeIfNew("another 2", anotherObj2)) + .isNotEqualTo(anotherObj2); + } + + if (attempt < 4) { + // retry + return Optional.empty(); + } + + var resultObj = CommitTestObj.builder().text("result"); + + var r = state.commitResult("foo", resultObj, refObj); + expectedObjs.add(objRef(r.orElseThrow())); + return r; + })) + .contains("foo"); + + soft.assertThat(expectedObjs).hasSize(3).doesNotHaveDuplicates(); + + // 4 attempts, 2 x 'AnotherTestObj' + soft.assertThat(createdObjs).hasSize(4 * 2).doesNotHaveDuplicates(); + + var unexpectedObjs = new HashSet<>(createdObjs); + expectedObjs.forEach(unexpectedObjs::remove); + soft.assertThat(unexpectedObjs).hasSize(6); + + soft.assertThat(persistence.fetchMany(Obj.class, withPartNum1(unexpectedObjs))) + .hasSize(6) + .containsOnlyNulls(); + soft.assertThat(persistence.fetchMany(Obj.class, withPartNum1(expectedObjs))) + .hasSize(3) + .doesNotContainNull(); + soft.assertThat(persistence.fetch(objRef(initialObj.withNumParts(1)), CommitTestObj.class)) + .isEqualTo(initialObj.withNumParts(1)); + } + + @Test + public void writeOrReplaceSuccessfulCommitAfterFourRetries(TestInfo testInfo) throws Exception { + var initialObj = + persistence.write( + CommitTestObj.builder() + .id(persistence.generateId()) + .text("initial") + .seq(1) + .tail(new long[0]) + .build(), + CommitTestObj.class); + var referenceName = testInfo.getTestMethod().orElseThrow().getName(); + + var createdObjs = new ArrayList(); + var expectedObjs = new ArrayList(); + var iteration = new AtomicInteger(); + + persistence.write(initialObj, CommitTestObj.class); + persistence.createReference(referenceName, Optional.of(objRef(initialObj))); + + soft.assertThat( + persistence + .createCommitter(referenceName, CommitTestObj.class, String.class) + .commit( + (state, refObjSupplier) -> { + var refObj = refObjSupplier.get(); + soft.assertThat(refObj).get().isEqualTo(initialObj).isNotSameAs(initialObj); + + var attempt = iteration.incrementAndGet(); + + var anotherObj1 = + AnotherTestObj.builder() + .id(persistence.generateId()) + .text("another 1, attempt " + attempt) + .build(); + var anotherObj2 = + AnotherTestObj.builder() + .id(persistence.generateId()) + .text("another 2, attempt " + attempt) + .build(); + + createdObjs.add(objRef(anotherObj1)); + createdObjs.add(objRef(anotherObj2)); + + if (attempt == 1) { + soft.assertThat(state.getWrittenByKey("another 1")).isNull(); + soft.assertThat(state.getWrittenByKey("another 2")).isNull(); + } else { + soft.assertThat(state.getWrittenByKey("another 1")).isNotNull(); + soft.assertThat(state.getWrittenByKey("another 2")).isNotNull(); + } + + state.writeOrReplace("another 1", anotherObj1); + state.writeOrReplace("another 2", anotherObj2); + + soft.assertThat(state.getWrittenByKey("another 1")).isNotNull(); + soft.assertThat(state.getWrittenByKey("another 2")).isNotNull(); + + if (attempt < 4) { + // retry + return Optional.empty(); + } + + var resultObj = CommitTestObj.builder().text("result"); + + expectedObjs.add(objRef(anotherObj1)); + expectedObjs.add(objRef(anotherObj2)); + + var r = state.commitResult("foo", resultObj, refObj); + + expectedObjs.add(objRef(r.orElseThrow())); + + return r; + })) + .contains("foo"); + + soft.assertThat(expectedObjs).hasSize(3).doesNotHaveDuplicates(); + + // 4 attempts, 2 x 'AnotherTestObj' + soft.assertThat(createdObjs).hasSize(4 * 2).doesNotHaveDuplicates(); + + var unexpectedObjs = new HashSet<>(createdObjs); + expectedObjs.forEach(unexpectedObjs::remove); + soft.assertThat(unexpectedObjs).hasSize(6); + + soft.assertThat(persistence.fetchMany(Obj.class, withPartNum1(unexpectedObjs))) + .hasSize(6) + .containsOnlyNulls(); + soft.assertThat(persistence.fetchMany(Obj.class, withPartNum1(expectedObjs))) + .hasSize(3) + .doesNotContainNull(); + soft.assertThat(persistence.fetch(objRef(initialObj.withNumParts(1)), CommitTestObj.class)) + .isEqualTo(initialObj); + } + + @Test + public void failingCommitMustDeleteAllObjs(TestInfo testInfo) { + var initialObj = + persistence.write( + CommitTestObj.builder() + .id(persistence.generateId()) + .text("initial") + .seq(1) + .tail(new long[0]) + .build(), + CommitTestObj.class); + var referenceName = testInfo.getTestMethod().orElseThrow().getName(); + + var createdObjs = new ArrayList(); + var iteration = new AtomicInteger(); + + persistence.write(initialObj, CommitTestObj.class); + persistence.createReference(referenceName, Optional.of(objRef(initialObj))); + + soft.assertThatThrownBy( + () -> + persistence + .createCommitter(referenceName, CommitTestObj.class, String.class) + .commit( + (state, refObjSupplier) -> { + soft.assertThat(refObjSupplier.get()) + .get() + .isEqualTo(initialObj) + .isNotSameAs(initialObj); + + var attempt = iteration.incrementAndGet(); + + var anotherObj1 = + AnotherTestObj.builder() + .id(persistence.generateId()) + .text("another 1, attempt " + attempt) + .build(); + var anotherObj2 = + AnotherTestObj.builder() + .id(persistence.generateId()) + .text("another 2, attempt " + attempt) + .build(); + + createdObjs.add(objRef(anotherObj1)); + createdObjs.add(objRef(anotherObj2)); + + state.writeOrReplace("another 1 / " + attempt, anotherObj1); + state.writeOrReplace("another 2 / " + attempt, anotherObj2); + + if (attempt < 4) { + // retry + return Optional.empty(); + } + + throw new CommitException("failed commit") {}; + })) + .isInstanceOf(CommitException.class) + .hasMessage("failed commit"); + + // 4 attempts, 2 x 'AnotherTestObj' + soft.assertThat(createdObjs).hasSize(4 * 2).doesNotHaveDuplicates(); + + soft.assertThat(persistence.fetchMany(Obj.class, withPartNum1(createdObjs))) + .hasSize(8) + .containsOnlyNulls(); + soft.assertThat(persistence.fetch(objRef(initialObj.withNumParts(1)), CommitTestObj.class)) + .isEqualTo(initialObj.withNumParts(1)); + } + + @Test + public void sameRefPointerMustNotWriteObjs(TestInfo testInfo) { + var initialObj = + persistence.write( + CommitTestObj.builder() + .id(persistence.generateId()) + .text("initial") + .seq(1) + .tail(new long[0]) + .build(), + CommitTestObj.class); + var referenceName = testInfo.getTestMethod().orElseThrow().getName(); + + var createdObjs = new ArrayList(); + var iteration = new AtomicInteger(); + + persistence.write(initialObj, CommitTestObj.class); + persistence.createReference(referenceName, Optional.of(objRef(initialObj))); + + soft.assertThatIllegalStateException() + .isThrownBy( + () -> + persistence + .createCommitter(referenceName, CommitTestObj.class, String.class) + .commit( + (state, refObjSupplier) -> { + soft.assertThat(refObjSupplier.get()) + .get() + .isEqualTo(initialObj) + .isNotSameAs(initialObj); + + var attempt = iteration.incrementAndGet(); + + var anotherObj1 = + AnotherTestObj.builder() + .id(persistence.generateId()) + .text("another 1, attempt " + attempt) + .build(); + var anotherObj2 = + AnotherTestObj.builder() + .id(persistence.generateId()) + .text("another 2, attempt " + attempt) + .build(); + + createdObjs.add(objRef(anotherObj1)); + createdObjs.add(objRef(anotherObj2)); + + state.writeOrReplace("another 1 / " + attempt, anotherObj1); + state.writeOrReplace("another 2 / " + attempt, anotherObj2); + + if (attempt < 4) { + // retry + return Optional.empty(); + } + + return Optional.of(initialObj); + })) + .withMessage( + "CommitRetryable.attempt() returned the current reference's pointer, in this case it must not attempt to persist any objects"); + + // 4 attempts, 2 x 'AnotherTestObj' + soft.assertThat(createdObjs).hasSize(4 * 2).doesNotHaveDuplicates(); + + soft.assertThat(persistence.fetchMany(Obj.class, withPartNum1(createdObjs))) + .hasSize(8) + .containsOnlyNulls(); + soft.assertThat(persistence.fetch(objRef(initialObj.withNumParts(1)), CommitTestObj.class)) + .isEqualTo(initialObj.withNumParts(1)); + } + + @Test + public void sameRefPointerMustNotModify(TestInfo testInfo) { + var initialObj = + persistence.write( + CommitTestObj.builder() + .id(persistence.generateId()) + .text("initial") + .seq(1) + .tail(new long[0]) + .build(), + CommitTestObj.class); + var referenceName = testInfo.getTestMethod().orElseThrow().getName(); + + var iteration = new AtomicInteger(); + + persistence.write(initialObj, CommitTestObj.class); + persistence.createReference(referenceName, Optional.of(objRef(initialObj))); + + soft.assertThatIllegalStateException() + .isThrownBy( + () -> + persistence + .createCommitter(referenceName, CommitTestObj.class, String.class) + .commit( + (state, refObjSupplier) -> { + soft.assertThat(refObjSupplier.get()) + .get() + .isEqualTo(initialObj) + .isNotSameAs(initialObj); + + var attempt = iteration.incrementAndGet(); + + if (attempt < 4) { + // retry + return Optional.empty(); + } + + return Optional.of( + CommitTestObj.builder() + .from(initialObj) + .optional("some optional") + .build()); + })) + .withMessage( + "CommitRetryable.attempt() must not modify the returned object when using the same ID"); + } + + @Test + public void sameRefPointer(TestInfo testInfo) { + var initialObj = + persistence.write( + CommitTestObj.builder() + .id(persistence.generateId()) + .text("initial") + .seq(1) + .tail(new long[0]) + .build(), + CommitTestObj.class); + var referenceName = testInfo.getTestMethod().orElseThrow().getName(); + + var iteration = new AtomicInteger(); + + persistence.write(initialObj, CommitTestObj.class); + persistence.createReference(referenceName, Optional.of(objRef(initialObj))); + + soft.assertThatCode( + () -> + persistence + .createCommitter(referenceName, CommitTestObj.class, CommitTestObj.class) + .commit( + (state, refObjSupplier) -> { + soft.assertThat(refObjSupplier.get()) + .get() + .isEqualTo(initialObj) + .isNotSameAs(initialObj); + + var attempt = iteration.incrementAndGet(); + + if (attempt < 4) { + // retry + return Optional.empty(); + } + + return Optional.of(initialObj); + })) + .doesNotThrowAnyException(); + + soft.assertThat(persistence.fetch(objRef(initialObj), CommitTestObj.class)) + .isEqualTo(initialObj); + } + + static ObjRef[] withPartNum1(Collection src) { + return src.stream().map(o -> ObjRef.objRef(o.type(), o.id(), 1)).toArray(ObjRef[]::new); + } +} diff --git a/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/commits/SimpleCommitTestObj.java b/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/commits/SimpleCommitTestObj.java new file mode 100644 index 0000000000..6cb6a0a916 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/commits/SimpleCommitTestObj.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.impl.commits; + +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.ObjType; + +/** A concrete object */ +@PolarisImmutable +@JsonSerialize(as = ImmutableSimpleCommitTestObj.class) +@JsonDeserialize(as = ImmutableSimpleCommitTestObj.class) +public interface SimpleCommitTestObj extends BaseCommitObj { + ObjType TYPE = new SimpleCommitTestObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + String payload(); + + final class SimpleCommitTestObjType extends AbstractObjType { + public SimpleCommitTestObjType() { + super("test-s-c", "simple commit", SimpleCommitTestObj.class); + } + } + + interface Builder extends BaseCommitObj.Builder {} +} diff --git a/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/indexes/KeyIndexTestSet.java b/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/indexes/KeyIndexTestSet.java new file mode 100644 index 0000000000..58febec15b --- /dev/null +++ b/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/indexes/KeyIndexTestSet.java @@ -0,0 +1,301 @@ +/* + * 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.impl.indexes; + +import static java.lang.Math.pow; +import static java.util.UUID.randomUUID; +import static org.apache.polaris.persistence.nosql.api.index.IndexKey.key; +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.impl.indexes.IndexesInternal.deserializeStoreIndex; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.indexElement; +import static org.apache.polaris.persistence.nosql.impl.indexes.IndexesInternal.newStoreIndex; + +import com.google.common.collect.Sets; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; +import java.util.zip.GZIPInputStream; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.index.IndexKey; +import org.apache.polaris.persistence.nosql.api.index.IndexValueSerializer; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.SimpleTestObj; +import org.assertj.core.util.Preconditions; +import org.immutables.value.Value; + +/** + * Generates a configurable set {@link IndexKey}s and test helper functionality to de-serialize + * indexes using this set of keys. + */ +@PolarisImmutable +public interface KeyIndexTestSet { + + static KeyIndexTestSet basicIndexTestSet() { + var idGen = new AtomicLong(); + return KeyIndexTestSet.newGenerator() + .elementSupplier( + key -> indexElement(key, objRef(SimpleTestObj.TYPE, idGen.incrementAndGet(), 1))) + .elementSerializer(OBJ_REF_SERIALIZER) + .build() + .generateIndexTestSet(); + } + + @Value.Parameter(order = 1) + List keys(); + + @Value.Parameter(order = 2) + ByteBuffer serialized(); + + default ByteBuffer serializedSafe() { + return serialized().duplicate(); + } + + @Value.Parameter(order = 3) + IndexSpi keyIndex(); + + @Value.Parameter(order = 4) + IndexSpi sourceKeyIndex(); + + static KeyIndexTestSet of( + List keys, + ByteBuffer serialized, + IndexSpi keyIndex, + IndexSpi sourceKeyIndex) { + return ImmutableKeyIndexTestSet.of(keys, serialized, keyIndex, sourceKeyIndex); + } + + static ImmutableIndexTestSetGenerator.Builder newGenerator() { + return ImmutableIndexTestSetGenerator.builder(); + } + + @FunctionalInterface + interface KeySet { + List keys(); + } + + /** + * Generates {@link IndexKey}s consisting of a single element from the string representation of + * random UUIDs. + */ + @PolarisImmutable + abstract class RandomUuidKeySet implements KeySet { + @Value.Default + public int numKeys() { + return 1000; + } + + @Override + public List keys() { + Set keys = new TreeSet<>(); + for (int i = 0; i < numKeys(); i++) { + keys.add(key(randomUUID().toString())); + } + return new ArrayList<>(keys); + } + } + + /** + * Generates {@link IndexKey}s based on realistic name patterns using a configurable amount of + * namespace levels, namespaces per level and tables per namespace. Key elements are derived from + * a set of more than 80000 words, each at least 10 characters long. The {@link #deterministic()} + * flag specifies whether the words are chosen deterministically. + */ + @PolarisImmutable + abstract class RealisticKeySet implements KeySet { + @Value.Default + public int namespaceLevels() { + return 1; + } + + @Value.Default + public int foldersPerLevel() { + return 5; + } + + @Value.Default + public int tablesPerNamespace() { + return 20; + } + + @Value.Default + public boolean deterministic() { + return true; + } + + @Override + public List keys() { + // This is the fastest way to generate a ton of keys, tested using profiling/JMH. + int namespacesFolders = (int) pow(namespaceLevels(), foldersPerLevel()); + Set namespaces = + Sets.newHashSetWithExpectedSize( + namespacesFolders); // actual value is higher, but that's fine here + Set keys = new TreeSet<>(); + + generateKeys(null, 0, namespaces, keys); + + return new ArrayList<>(keys); + } + + private void generateKeys( + IndexKey current, int level, Set namespaces, Set keys) { + if (level > namespaceLevels()) { + return; + } + + if (level == namespaceLevels()) { + // generate tables + for (int i = 0; i < tablesPerNamespace(); i++) { + generateTableKey(current, level, keys, i); + } + return; + } + + for (int i = 0; i < foldersPerLevel(); i++) { + IndexKey folderKey = generateFolderKey(current, level, namespaces, i); + generateKeys(folderKey, level + 1, namespaces, keys); + } + } + + private void generateTableKey(IndexKey current, int level, Set keys, int i) { + if (deterministic()) { + IndexKey tableKey = key(current.toString() + "\u0000" + Words.WORDS.get(i)); + Preconditions.checkArgument( + keys.add(tableKey), "table - current:%s level:%s i:%s", current, level, i); + } else { + while (true) { + IndexKey tableKey = key(current.toString() + "\u0000" + randomWord()); + if (keys.add(tableKey)) { + break; + } + } + } + } + + private IndexKey generateFolderKey( + IndexKey current, int level, Set namespaces, int i) { + if (deterministic()) { + String folder = Words.WORDS.get(i); + IndexKey folderKey = current != null ? key(current + "\u0000" + folder) : key(folder); + Preconditions.checkArgument( + namespaces.add(folderKey), "namespace - current:%s level:%s i:%s", current, level, i); + return folderKey; + } else { + while (true) { + String folder = randomWord(); + IndexKey folderKey = current != null ? key(current + "\u0000" + folder) : key(folder); + if (namespaces.add(folderKey)) { + return folderKey; + } + } + } + } + } + + @PolarisImmutable + abstract class IndexTestSetGenerator { + + public abstract Function> elementSupplier(); + + public abstract IndexValueSerializer elementSerializer(); + + @Value.Default + public KeySet keySet() { + return ImmutableRealisticKeySet.builder().build(); + } + + public final KeyIndexTestSet generateIndexTestSet() { + var index = newStoreIndex(elementSerializer()); + + var keys = keySet().keys(); + + for (var key : keys) { + index.add(elementSupplier().apply(key)); + } + + var serialized = index.serialize(); + + // Re-serialize to have "clean" internal values in KeyIndexImpl + var keyIndex = deserializeStoreIndex(serialized.duplicate(), elementSerializer()); + + return KeyIndexTestSet.of(keys, keyIndex.serialize(), keyIndex, index); + } + } + + static String randomWord() { + return Words.WORDS.get(ThreadLocalRandom.current().nextInt(Words.WORDS.size())); + } + + default IndexKey randomKey() { + var k = keys(); + var i = ThreadLocalRandom.current().nextInt(k.size()); + return k.get(i); + } + + default ByteBuffer serialize() { + return keyIndex().serialize(); + } + + default IndexSpi deserialize() { + return deserializeStoreIndex(serializedSafe(), OBJ_REF_SERIALIZER); + } + + default IndexElement randomGetKey() { + IndexKey key = randomKey(); + return keyIndex().getElement(key); + } + + class Words { + private static final List WORDS = new ArrayList<>(); + + static { + // Word list "generated" via: + // + // curl https://raw.githubusercontent.com/sindresorhus/word-list/main/words.txt | + // while read word; do + // [[ ${#word} -gt 10 ]] && echo $word + // done | gzip > words.gz + // + try { + var words = KeyIndexTestSet.class.getResource("words.gz"); + var conn = Objects.requireNonNull(words, "words.gz resource not found").openConnection(); + try (var br = + new BufferedReader( + new InputStreamReader( + new GZIPInputStream(conn.getInputStream()), StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + WORDS.add(line); + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/indexes/Util.java b/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/indexes/Util.java new file mode 100644 index 0000000000..33353f8818 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/indexes/Util.java @@ -0,0 +1,48 @@ +/* + * 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.impl.indexes; + +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; + +import java.nio.ByteBuffer; +import java.util.concurrent.ThreadLocalRandom; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.api.obj.SimpleTestObj; + +public final class Util { + private Util() {} + + private static final char[] HEX = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + }; + + public static String asHex(ByteBuffer b) { + StringBuilder sb = new StringBuilder(); + for (int p = b.position(); p < b.limit(); p++) { + int v = b.get(p); + sb.append(HEX[(v >> 4) & 0xf]); + sb.append(HEX[v & 0xf]); + } + return sb.toString(); + } + + public static ObjRef randomObjId() { + return objRef(SimpleTestObj.TYPE, ThreadLocalRandom.current().nextLong(), 1); + } +} diff --git a/persistence/nosql/persistence/impl/src/testFixtures/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType b/persistence/nosql/persistence/impl/src/testFixtures/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType new file mode 100644 index 0000000000..7f099c2c21 --- /dev/null +++ b/persistence/nosql/persistence/impl/src/testFixtures/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.impl.commits.SimpleCommitTestObj$SimpleCommitTestObjType diff --git a/persistence/nosql/persistence/impl/src/testFixtures/resources/org/apache/polaris/persistence/nosql/impl/indexes/words.gz b/persistence/nosql/persistence/impl/src/testFixtures/resources/org/apache/polaris/persistence/nosql/impl/indexes/words.gz new file mode 100644 index 0000000000..2066497872 Binary files /dev/null and b/persistence/nosql/persistence/impl/src/testFixtures/resources/org/apache/polaris/persistence/nosql/impl/indexes/words.gz differ diff --git a/persistence/nosql/persistence/testextension/build.gradle.kts b/persistence/nosql/persistence/testextension/build.gradle.kts new file mode 100644 index 0000000000..b551e655bb --- /dev/null +++ b/persistence/nosql/persistence/testextension/build.gradle.kts @@ -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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = + "Polaris NoSQL persistence JUnit test suite to use Polaris NoSQL persistence in tests, no production code." + +dependencies { + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-persistence-nosql-impl")) + implementation(project(":polaris-idgen-api")) + implementation(project(":polaris-idgen-impl")) + implementation(project(":polaris-idgen-spi")) + + implementation(platform(libs.micrometer.bom)) + implementation("io.micrometer:micrometer-core") + + implementation(libs.guava) + implementation(libs.slf4j.api) + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + + compileOnly(platform(libs.jackson.bom)) + compileOnly("com.fasterxml.jackson.core:jackson-annotations") + + implementation(platform(libs.junit.bom)) + implementation("org.junit.jupiter:junit-jupiter") +} diff --git a/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/BackendSpec.java b/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/BackendSpec.java new file mode 100644 index 0000000000..b72ee832e2 --- /dev/null +++ b/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/BackendSpec.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.testextension; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface BackendSpec { + /** + * The name of the backend to use, can be left empty to choose the only backend available on the + * classpath. + */ + String name() default ""; + + /** Type of the backend test factory to use, mutually exclusive to {@link #name()}. */ + Class factory() default BackendTestFactory.class; +} diff --git a/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/BackendTestFactory.java b/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/BackendTestFactory.java new file mode 100644 index 0000000000..77216ecc61 --- /dev/null +++ b/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/BackendTestFactory.java @@ -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. + */ +package org.apache.polaris.persistence.nosql.testextension; + +import java.util.Optional; +import org.apache.polaris.persistence.nosql.api.backend.Backend; + +public interface BackendTestFactory extends AutoCloseable { + Backend createNewBackend() throws Exception; + + void start() throws Exception; + + /** + * For backends relying on containers, start the backend with an optional container network ID. + * Behaves the same as {@link #start()} by default. + */ + default void start(Optional containerNetworkId) throws Exception { + start(); + } + + void stop() throws Exception; + + String name(); + + @Override + default void close() throws Exception { + stop(); + } +} diff --git a/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/BackendTestFactoryLoader.java b/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/BackendTestFactoryLoader.java new file mode 100644 index 0000000000..999a969d9b --- /dev/null +++ b/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/BackendTestFactoryLoader.java @@ -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. + */ +package org.apache.polaris.persistence.nosql.testextension; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; + +import jakarta.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; +import java.util.ServiceLoader; +import java.util.function.Predicate; + +public final class BackendTestFactoryLoader { + private BackendTestFactoryLoader() {} + + @Nonnull + public static BackendTestFactory findFactoryByName(@Nonnull String name) { + return findFactory(f -> f.name().equals(name)); + } + + @Nonnull + public static BackendTestFactory findAny() { + return findFactory(x -> true); + } + + @Nonnull + public static BackendTestFactory findFactory(@Nonnull Predicate filter) { + ServiceLoader loader = ServiceLoader.load(BackendTestFactory.class); + List candidates = new ArrayList<>(); + boolean any = false; + for (BackendTestFactory backendFactory : loader) { + any = true; + if (filter.test(backendFactory)) { + candidates.add(backendFactory); + } + } + checkState(any, "No BackendFactory on class path"); + checkArgument(!candidates.isEmpty(), "No BackendFactory matched the given filter"); + checkState(candidates.size() == 1, "More than one BackendFactory matched the given filter"); + + return candidates.getFirst(); + } +} diff --git a/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/PersistenceTestExtension.java b/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/PersistenceTestExtension.java new file mode 100644 index 0000000000..c2f5e3f972 --- /dev/null +++ b/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/PersistenceTestExtension.java @@ -0,0 +1,360 @@ +/* + * 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.testextension; + +import static com.google.common.base.Preconditions.checkState; +import static java.util.function.Function.identity; +import static org.apache.polaris.persistence.nosql.testextension.PolarisPersistence.RANDOM_REALM; +import static org.junit.platform.commons.util.AnnotationUtils.findAnnotatedFields; +import static org.junit.platform.commons.util.AnnotationUtils.findAnnotation; +import static org.junit.platform.commons.util.ReflectionUtils.isPrivate; +import static org.junit.platform.commons.util.ReflectionUtils.makeAccessible; + +import java.lang.reflect.Field; +import java.lang.reflect.Parameter; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.apache.polaris.ids.api.IdGenerator; +import org.apache.polaris.ids.api.MonotonicClock; +import org.apache.polaris.ids.api.SnowflakeIdGenerator; +import org.apache.polaris.ids.impl.MonotonicClockImpl; +import org.apache.polaris.ids.impl.SnowflakeIdGeneratorFactory; +import org.apache.polaris.ids.spi.IdGeneratorSource; +import org.apache.polaris.misc.types.memorysize.MemorySize; +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.cache.CacheConfig; +import org.apache.polaris.persistence.nosql.api.cache.CacheSizing; +import org.apache.polaris.persistence.nosql.api.commit.FairRetriesType; +import org.apache.polaris.persistence.nosql.api.commit.RetryConfig; +import org.apache.polaris.persistence.nosql.impl.cache.PersistenceCaches; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; +import org.junit.platform.commons.util.ExceptionUtils; +import org.junit.platform.commons.util.ReflectionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class PersistenceTestExtension + implements BeforeAllCallback, BeforeEachCallback, ParameterResolver { + private static final Logger LOGGER = LoggerFactory.getLogger(PersistenceTestExtension.class); + + static final ExtensionContext.Namespace NAMESPACE = + ExtensionContext.Namespace.create(PersistenceTestExtension.class); + static final String KEY_BACKEND = "polaris-test-backend"; + static final String KEY_BACKEND_TEST_FACTORY = "polaris-test-backend-test-factory"; + static final String KEY_MONOTONIC_CLOCK = "polaris-monotonic-clock"; + static final String KEY_SNOWFLAKE_ID_GENERATOR = "polaris-snowflake-id-generator"; + + @Override + public void beforeAll(ExtensionContext extensionContext) { + var testClass = extensionContext.getRequiredTestClass(); + + findAnnotatedFields(testClass, PolarisPersistence.class, ReflectionUtils::isStatic) + .forEach(field -> injectField(extensionContext, null, field)); + } + + @Override + public void beforeEach(ExtensionContext extensionContext) { + extensionContext + .getRequiredTestInstances() + .getAllInstances() // + .forEach( + instance -> + findAnnotatedFields( + instance.getClass(), PolarisPersistence.class, ReflectionUtils::isNotStatic) + .forEach(field -> injectField(extensionContext, instance, field))); + } + + private void injectField(ExtensionContext extensionContext, Object instance, Field field) { + assertValidFieldCandidate(field); + try { + PolarisPersistence annotation = + findAnnotation(field, PolarisPersistence.class).orElseThrow(IllegalStateException::new); + + Object assign = resolve(annotation, field.getType(), extensionContext); + + makeAccessible(field).set(instance, assign); + } catch (Throwable t) { + ExceptionUtils.throwAsUncheckedException(t); + } + } + + @Override + public Object resolveParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + PolarisPersistence annotation = + parameterContext + .findAnnotation(PolarisPersistence.class) + .orElseThrow(IllegalStateException::new); + Parameter parameter = parameterContext.getParameter(); + + return resolve(annotation, parameter.getType(), extensionContext); + } + + private Object resolve( + PolarisPersistence annotation, Class type, ExtensionContext extensionContext) { + + if (MonotonicClock.class.isAssignableFrom(type)) { + return getOrCreateMonotonicClock(extensionContext); + } + if (IdGenerator.class.isAssignableFrom(type)) { + return getOrCreateSnowflakeIdGenerator(extensionContext); + } + + BackendSpec backendSpec = findBackendSpec(extensionContext); + checkState( + backendSpec != null, + "Cannot find backend spec for %s", + extensionContext.getRequiredTestClass()); + + if (BackendTestFactory.class.isAssignableFrom(type)) { + return getOrCreateBackendTestFactory(extensionContext, backendSpec); + } + if (Backend.class.isAssignableFrom(type)) { + return getOrCreateBackend(extensionContext, backendSpec); + } + if (Persistence.class.isAssignableFrom(type)) { + return createPersistence(annotation, extensionContext, backendSpec); + } + + throw new IllegalStateException("Unable to assign a field of type " + type); + } + + private BackendSpec findBackendSpec(ExtensionContext extensionContext) { + for (; extensionContext != null; extensionContext = extensionContext.getParent().orElse(null)) { + var maybe = + extensionContext.getTestClass().flatMap(c -> findAnnotation(c, BackendSpec.class, true)); + if (maybe.isPresent()) { + return maybe.get(); + } + } + return null; + } + + @Override + public boolean supportsParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + return parameterContext.isAnnotated(PolarisPersistence.class); + } + + private void assertValidFieldCandidate(Field field) { + if (!field.getType().isAssignableFrom(SnowflakeIdGenerator.class) + && !field.getType().isAssignableFrom(MonotonicClock.class) + && !field.getType().isAssignableFrom(Persistence.class) + && !field.getType().isAssignableFrom(Backend.class) + && !field.getType().isAssignableFrom(BackendTestFactory.class)) { + throw new ExtensionConfigurationException( + "Unsupported field type " + field.getType().getName()); + } + if (isPrivate(field)) { + throw new ExtensionConfigurationException( + String.format("field [%s] must not be private.", field)); + } + } + + private MonotonicClock getOrCreateMonotonicClock(ExtensionContext extensionContext) { + var store = extensionContext.getRoot().getStore(NAMESPACE); + return store + .getOrComputeIfAbsent( + KEY_MONOTONIC_CLOCK, + x -> new WrappedResource(MonotonicClockImpl.newDefaultInstance()), + WrappedResource.class) + .resource(); + } + + private SnowflakeIdGenerator getOrCreateSnowflakeIdGenerator(ExtensionContext extensionContext) { + var store = extensionContext.getRoot().getStore(NAMESPACE); + return store.getOrComputeIfAbsent( + KEY_SNOWFLAKE_ID_GENERATOR, + x -> + new SnowflakeIdGeneratorFactory() + .buildIdGenerator( + Map.of(), + new IdGeneratorSource() { + final MonotonicClock clock = getOrCreateMonotonicClock(extensionContext); + + @Override + public int nodeId() { + return 123; + } + + @Override + public long currentTimeMillis() { + return clock.currentTimeMillis(); + } + }), + SnowflakeIdGenerator.class); + } + + private BackendTestFactory getOrCreateBackendTestFactory( + ExtensionContext extensionContext, BackendSpec backendSpec) { + var store = extensionContext.getRoot().getStore(NAMESPACE); + var existingResource = store.get(KEY_BACKEND, WrappedResource.class); + var existing = + existingResource != null ? existingResource.resource() : null; + if (existing != null) { + if (isCompatible(backendSpec, existing)) { + return existing; + } + var previous = store.remove(KEY_BACKEND_TEST_FACTORY, WrappedResource.class); + LOGGER.info( + "Stopping previously used persistence backend test factory '{}' because it is incompatible with {}", + existing.name(), + backendSpec); + try { + previous.resource.close(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + LOGGER.info("Creating new persistence backend for {}", backendSpec); + var factory = + BackendTestFactoryLoader.findFactory( + f -> { + if (!backendSpec.name().isEmpty() && !backendSpec.name().equalsIgnoreCase(f.name())) { + return false; + } + return backendSpec.factory() == BackendTestFactory.class + || backendSpec.factory().isInstance(f); + }); + try { + factory.start(); + } catch (Exception e) { + throw new RuntimeException(e); + } + store.put(KEY_BACKEND_TEST_FACTORY, new WrappedResource(factory)); + return factory; + } + + private Backend getOrCreateBackend(ExtensionContext extensionContext, BackendSpec backendSpec) { + var store = extensionContext.getRoot().getStore(NAMESPACE); + var existingResource = store.get(KEY_BACKEND, WrappedResource.class); + var existing = existingResource != null ? existingResource.resource() : null; + if (existing != null) { + var existingFactory = + store.get(KEY_BACKEND_TEST_FACTORY, WrappedResource.class).resource(); + if (isCompatible(backendSpec, existingFactory)) { + return existing; + } + try { + var previous = store.remove(KEY_BACKEND, WrappedResource.class); + var backend = previous.resource(); + LOGGER.info( + "Closing previously used persistence backend '{}' because it is incompatible with {}", + backend.type(), + backendSpec); + backend.close(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + var testFactory = getOrCreateBackendTestFactory(extensionContext, backendSpec); + try { + var instance = testFactory.createNewBackend(); + var info = instance.setupSchema().orElse(""); + LOGGER.info("Opened new persistence backend '{}' {}", instance.type(), info); + store.put(KEY_BACKEND, new WrappedResource(instance)); + return instance; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private boolean isCompatible(BackendSpec backendSpec, BackendTestFactory existingFactory) { + if (backendSpec.factory() != BackendTestFactory.class + && !backendSpec.factory().isAssignableFrom(existingFactory.getClass())) { + return false; + } + if (!backendSpec.name().isEmpty()) { + return backendSpec.name().equals(existingFactory.name()); + } + return true; + } + + private Persistence createPersistence( + PolarisPersistence annotation, ExtensionContext extensionContext, BackendSpec backendSpec) { + var clock = getOrCreateMonotonicClock(extensionContext); + var idGenerator = getOrCreateSnowflakeIdGenerator(extensionContext); + + var backend = getOrCreateBackend(extensionContext, backendSpec); + var realmId = + RANDOM_REALM.equals(annotation.realmId()) + ? UUID.randomUUID().toString() + : annotation.realmId(); + var persistenceConfig = PersistenceParams.BuildablePersistenceParams.builder(); + if (annotation.fastRetries()) { + persistenceConfig.retryConfig( + RetryConfig.BuildableRetryConfig.builder() + .initialSleepLower(Duration.ZERO) + .maxSleep(Duration.ofMillis(1)) + .initialSleepUpper(Duration.ofMillis(1)) + .timeout(Duration.ofMinutes(5)) + .retries(Integer.MAX_VALUE) + .fairRetries(FairRetriesType.UNFAIR) + .build()); + } + var uncachedPersistence = + backend.newPersistence(identity(), persistenceConfig.build(), realmId, clock, idGenerator); + + if (!annotation.caching()) { + return uncachedPersistence; + } + + return PersistenceCaches.newBackend( + CacheConfig.BuildableCacheConfig.builder() + .sizing(CacheSizing.builder().fixedSize(MemorySize.ofMega(32)).build()) + .clockNanos(clock::nanoTime) + .referenceTtl(Duration.ofMinutes(1)) + .referenceNegativeTtl(Duration.ofSeconds(1)) + .build(), + Optional.empty()) + .wrap(uncachedPersistence); + } + + static final class WrappedResource implements AutoCloseable { + final AutoCloseable resource; + + WrappedResource(AutoCloseable resource) { + this.resource = resource; + } + + @Override + public void close() throws Exception { + resource.close(); + } + + @SuppressWarnings("unchecked") + X resource() { + return (X) resource; + } + } +} diff --git a/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/PolarisPersistence.java b/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/PolarisPersistence.java new file mode 100644 index 0000000000..d44d5d7557 --- /dev/null +++ b/persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/PolarisPersistence.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.testextension; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface PolarisPersistence { + /** Configure a fixed realm ID. Default is to use a random ID. */ + String realmId() default RANDOM_REALM; + + boolean caching() default false; + + boolean fastRetries() default false; + + String RANDOM_REALM = "_RANDOM_REALM_SENTINEL_VALUE_"; +}