From 335632da1496c07e21a094ab29eb2f437311fae3 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Tue, 4 Nov 2025 16:18:20 +0100 Subject: [PATCH] NoSQL: database agnostic implementation + in-memory backend This change contains the database agnostic implementation plus the in-memory backend used for testing purposes, and a Junit extension. These three modules are difficult to put into isolated PRs. The "main implementation" contains the commit-logic, indexes-logic and the caching part. `PersistenceImplementation` is (more or less) a wrapper providing higher-level functionality backed by a database's `Backend` implementation. The latter provides the bare minimum functionality. Other implementations of the `Persistence` interface are just to transparently add caching and commit-attempt specific case. No call site needs to bother about the actual implementation and/or its layers. Tests in the `polaris-persistence-nosql-impl` module use the in-memory backend via the Junit extension. Common tests for all backends, in-memory in this PR and MongoDB in a follow-up, are in the testFixtures of the `polaris-persistence-nosql-impl`. --- bom/build.gradle.kts | 4 + build.gradle.kts | 5 + gradle/libs.versions.toml | 4 +- gradle/projects.main.properties | 4 + .../persistence/db/inmemory/build.gradle.kts | 68 ++ .../nosql/inmemory/InMemoryBackend.java | 288 +++++ .../nosql/inmemory/InMemoryBackendConfig.java | 21 + .../inmemory/InMemoryBackendFactory.java | 50 + .../nosql/inmemory/InMemoryConfiguration.java | 24 + .../persistence/nosql/inmemory/ObjKey.java | 47 + .../persistence/nosql/inmemory/RefKey.java | 39 + .../nosql/inmemory/SerializedObj.java | 22 + ...rsistence.nosql.api.backend.BackendFactory | 20 + .../inmemory/TestInMemoryPersistence.java | 34 + .../src/test/resources/logback-test.xml | 30 + .../inmemory/InMemoryBackendTestFactory.java | 48 + ...nce.nosql.testextension.BackendTestFactory | 20 + .../nosql/persistence/impl/build.gradle.kts | 92 ++ .../indexes/RandomUuidKeyIndexImplBench.java | 90 ++ .../indexes/RealisticKeyIndexImplBench.java | 144 +++ .../persistence/nosql/impl/Identifiers.java | 45 + .../nosql/impl/MultiByteArrayInputStream.java | 82 ++ .../nosql/impl/PersistenceImplementation.java | 608 +++++++++++ .../impl/cache/CachingPersistenceImpl.java | 388 +++++++ .../impl/cache/CaffeineCacheBackend.java | 722 +++++++++++++ .../DistributedInvalidationsCacheBackend.java | 117 +++ .../nosql/impl/cache/NoopCacheBackend.java | 83 ++ .../impl/cache/PersistenceCacheDecorator.java | 102 ++ .../nosql/impl/cache/PersistenceCaches.java | 34 + .../nosql/impl/commits/CommitFactory.java | 58 ++ .../impl/commits/CommitSynchronizer.java | 34 + .../nosql/impl/commits/CommitsImpl.java | 228 ++++ .../nosql/impl/commits/CommitterImpl.java | 514 +++++++++ .../impl/commits/CommitterWithStats.java | 39 + .../impl/commits/DelegatingPersistence.java | 243 +++++ .../commits/ExclusiveCommitSynchronizer.java | 53 + .../nosql/impl/commits/retry/FairRetries.java | 36 + .../nosql/impl/commits/retry/RetryLoop.java | 35 + .../impl/commits/retry/RetryLoopImpl.java | 156 +++ .../commits/retry/RetryStatsConsumer.java | 31 + .../nosql/impl/commits/retry/Retryable.java | 35 + .../commits/retry/SleepingFairRetries.java | 67 ++ .../impl/commits/retry/UnfairRetries.java | 31 + .../impl/indexes/AbstractIndexElement.java | 58 ++ .../indexes/AbstractLayeredIndexImpl.java | 255 +++++ .../impl/indexes/DirectIndexElement.java | 60 ++ .../impl/indexes/ImmutableEmptyIndexImpl.java | 163 +++ .../nosql/impl/indexes/IndexElement.java | 30 + .../nosql/impl/indexes/IndexImpl.java | 982 ++++++++++++++++++ .../nosql/impl/indexes/IndexLoader.java | 41 + .../nosql/impl/indexes/IndexSpi.java | 197 ++++ .../nosql/impl/indexes/IndexStripeObj.java | 51 + .../nosql/impl/indexes/IndexesInternal.java | 220 ++++ .../nosql/impl/indexes/IndexesProvider.java | 73 ++ .../nosql/impl/indexes/LazyIndexImpl.java | 199 ++++ .../nosql/impl/indexes/ReadOnlyIndex.java | 64 ++ .../indexes/ReadOnlyLayeredIndexImpl.java | 86 ++ .../nosql/impl/indexes/StripedIndexImpl.java | 361 +++++++ .../nosql/impl/indexes/SupplyOnce.java | 68 ++ .../impl/indexes/UpdatableIndexImpl.java | 344 ++++++ .../nosql/impl/indexes/package-info.java | 20 + .../src/main/resources/META-INF/beans.xml | 24 + ....polaris.persistence.nosql.api.obj.ObjType | 20 + .../impl/TestMultiByteArrayInputStream.java | 97 ++ .../nosql/impl/cache/DefaultCachingObj.java | 46 + .../nosql/impl/cache/DynamicCachingObj.java | 52 + .../nosql/impl/cache/NegativeCachingObj.java | 50 + .../nosql/impl/cache/NonCachingObj.java | 46 + .../nosql/impl/cache/TestCacheConfig.java | 87 ++ .../nosql/impl/cache/TestCacheExpiration.java | 128 +++ .../nosql/impl/cache/TestCacheKeys.java | 144 +++ .../nosql/impl/cache/TestCacheOvershoot.java | 149 +++ .../nosql/impl/cache/TestCacheSizing.java | 137 +++ .../cache/TestCachingInMemoryPersist.java | 120 +++ .../cache/TestDistributedInvalidations.java | 281 +++++ .../impl/cache/TestReferenceCaching.java | 193 ++++ .../nosql/impl/commits/TestCommitLogImpl.java | 24 + .../nosql/impl/commits/TestCommitterImpl.java | 24 + .../retry/TestRetryLoopConcurrency.java | 146 +++ .../impl/commits/retry/TestRetryLoopImpl.java | 362 +++++++ .../nosql/impl/indexes/ObjTestValue.java | 104 ++ .../indexes/TestAbstractLayeredIndexImpl.java | 237 +++++ .../indexes/TestImmutableEmptyIndexImpl.java | 80 ++ .../nosql/impl/indexes/TestIndexImpl.java | 879 ++++++++++++++++ .../nosql/impl/indexes/TestKeyIndexSets.java | 94 ++ .../nosql/impl/indexes/TestLazyIndexImpl.java | 229 ++++ .../indexes/TestReadOnlyLayeredIndexImpl.java | 50 + .../impl/indexes/TestStripedIndexImpl.java | 538 ++++++++++ .../nosql/impl/indexes/TestSupplyOnce.java | 104 ++ .../impl/indexes/TestUpdatableIndexImpl.java | 398 +++++++ ....polaris.persistence.nosql.api.obj.ObjType | 23 + .../impl/src/test/resources/logback-test.xml | 30 + .../nosql/impl/AbstractPersistenceTests.java | 844 +++++++++++++++ .../impl/commits/BaseTestCommitLogImpl.java | 77 ++ .../impl/commits/BaseTestCommitterImpl.java | 785 ++++++++++++++ .../impl/commits/SimpleCommitTestObj.java | 49 + .../nosql/impl/indexes/KeyIndexTestSet.java | 301 ++++++ .../persistence/nosql/impl/indexes/Util.java | 48 + ....polaris.persistence.nosql.api.obj.ObjType | 20 + .../persistence/nosql/impl/indexes/words.gz | Bin 0 -> 261442 bytes .../testextension/build.gradle.kts | 49 + .../nosql/testextension/BackendSpec.java | 39 + .../testextension/BackendTestFactory.java | 45 + .../BackendTestFactoryLoader.java | 60 ++ .../PersistenceTestExtension.java | 360 +++++++ .../testextension/PolarisPersistence.java | 39 + 106 files changed, 15678 insertions(+), 1 deletion(-) create mode 100644 persistence/nosql/persistence/db/inmemory/build.gradle.kts create mode 100644 persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackend.java create mode 100644 persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackendConfig.java create mode 100644 persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackendFactory.java create mode 100644 persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryConfiguration.java create mode 100644 persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/ObjKey.java create mode 100644 persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/RefKey.java create mode 100644 persistence/nosql/persistence/db/inmemory/src/main/java/org/apache/polaris/persistence/nosql/inmemory/SerializedObj.java create mode 100644 persistence/nosql/persistence/db/inmemory/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.backend.BackendFactory create mode 100644 persistence/nosql/persistence/db/inmemory/src/test/java/org/apache/polaris/persistence/nosql/inmemory/TestInMemoryPersistence.java create mode 100644 persistence/nosql/persistence/db/inmemory/src/test/resources/logback-test.xml create mode 100644 persistence/nosql/persistence/db/inmemory/src/testFixtures/java/org/apache/polaris/persistence/nosql/inmemory/InMemoryBackendTestFactory.java create mode 100644 persistence/nosql/persistence/db/inmemory/src/testFixtures/resources/META-INF/services/org.apache.polaris.persistence.nosql.testextension.BackendTestFactory create mode 100644 persistence/nosql/persistence/impl/build.gradle.kts create mode 100644 persistence/nosql/persistence/impl/src/jmh/java/org/apache/polaris/persistence/nosql/impl/indexes/RandomUuidKeyIndexImplBench.java create mode 100644 persistence/nosql/persistence/impl/src/jmh/java/org/apache/polaris/persistence/nosql/impl/indexes/RealisticKeyIndexImplBench.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/Identifiers.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/MultiByteArrayInputStream.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/PersistenceImplementation.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/CachingPersistenceImpl.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/CaffeineCacheBackend.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/DistributedInvalidationsCacheBackend.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/NoopCacheBackend.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/PersistenceCacheDecorator.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/cache/PersistenceCaches.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitFactory.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitSynchronizer.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitsImpl.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitterImpl.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/CommitterWithStats.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/DelegatingPersistence.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/ExclusiveCommitSynchronizer.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/FairRetries.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/RetryLoop.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/RetryLoopImpl.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/RetryStatsConsumer.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/Retryable.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/SleepingFairRetries.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/commits/retry/UnfairRetries.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/AbstractIndexElement.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/AbstractLayeredIndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/DirectIndexElement.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/ImmutableEmptyIndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexElement.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexLoader.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexSpi.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexStripeObj.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexesInternal.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/IndexesProvider.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/LazyIndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/ReadOnlyIndex.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/ReadOnlyLayeredIndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/StripedIndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/SupplyOnce.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/UpdatableIndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/main/java/org/apache/polaris/persistence/nosql/impl/indexes/package-info.java create mode 100644 persistence/nosql/persistence/impl/src/main/resources/META-INF/beans.xml create mode 100644 persistence/nosql/persistence/impl/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/TestMultiByteArrayInputStream.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/DefaultCachingObj.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/DynamicCachingObj.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/NegativeCachingObj.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/NonCachingObj.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheConfig.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheExpiration.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheKeys.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheOvershoot.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCacheSizing.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestCachingInMemoryPersist.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestDistributedInvalidations.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/cache/TestReferenceCaching.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/TestCommitLogImpl.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/TestCommitterImpl.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/retry/TestRetryLoopConcurrency.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/commits/retry/TestRetryLoopImpl.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/ObjTestValue.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestAbstractLayeredIndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestImmutableEmptyIndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestIndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestKeyIndexSets.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestLazyIndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestReadOnlyLayeredIndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestStripedIndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestSupplyOnce.java create mode 100644 persistence/nosql/persistence/impl/src/test/java/org/apache/polaris/persistence/nosql/impl/indexes/TestUpdatableIndexImpl.java create mode 100644 persistence/nosql/persistence/impl/src/test/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType create mode 100644 persistence/nosql/persistence/impl/src/test/resources/logback-test.xml create mode 100644 persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/AbstractPersistenceTests.java create mode 100644 persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/commits/BaseTestCommitLogImpl.java create mode 100644 persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/commits/BaseTestCommitterImpl.java create mode 100644 persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/commits/SimpleCommitTestObj.java create mode 100644 persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/indexes/KeyIndexTestSet.java create mode 100644 persistence/nosql/persistence/impl/src/testFixtures/java/org/apache/polaris/persistence/nosql/impl/indexes/Util.java create mode 100644 persistence/nosql/persistence/impl/src/testFixtures/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType create mode 100644 persistence/nosql/persistence/impl/src/testFixtures/resources/org/apache/polaris/persistence/nosql/impl/indexes/words.gz create mode 100644 persistence/nosql/persistence/testextension/build.gradle.kts create mode 100644 persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/BackendSpec.java create mode 100644 persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/BackendTestFactory.java create mode 100644 persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/BackendTestFactoryLoader.java create mode 100644 persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/PersistenceTestExtension.java create mode 100644 persistence/nosql/persistence/testextension/src/main/java/org/apache/polaris/persistence/nosql/testextension/PolarisPersistence.java 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 0000000000000000000000000000000000000000..2066497872925d9afc5bcc817012bc67925dd249 GIT binary patch literal 261442 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%0Ae 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ਸMe38 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

Z5 z4O*mqrIFPj2^#Q4Qj!ww?lA{67&N;3{pE7QMri-?NBp}7WNq1O@8M;E$j1)q2t%)o zxJ4pm<3s5>pbU&{MlG>u8}D4gI3iJLjrVX2t}Q=asGzx#hufy{SyS7nJ9)Aa<9Nb5GdxT;k1*igWKbwrIeAnf{@)}Nl?z^wy7Onub5 zaTL<@(=Vdsx{siD)DVa&Cp4X?6Q)IXC<1B$RxZ+(xwin;^4Te9HC(%2w2r(-gPQsP zKb6Osg6?%x=egJLc=Pz3Z)b_jDB~N~h4hmq=6Dcr4g1f^FFRR}c9*(%;#lrdR~cfn z@ry@KG6XffhvrvXq9458P4t_mIup2w9~ng3AT*IDlHeHXA(%;?J5=+OnQES4 zH@$QCz42y}1#~6Nd$^L<@P5q`q0w~jA0VI-8ci=ei3QpW7eA!(Hn~0?PE?d_RuX}1 z>x2A^Jo+;wYBKy58~k2K2WYRzBswgc@Eu&a{l#q zqV|9H@H4#F3ohwtn19B8@9Osh1TyvHM)_9e=H0!EtYJYA(|hf|l-@0X?uIf<_LvQt zQX}u8I$FES-cD&neEi z@A|sw#e)pG5`k69BmU8bx);=BSnPXc)piLIiSf6{Gz{w;^7lJveexQ6(%y?AG@#yZ zA@q(EuwY2kR2j>wlH+gb;5z<8m$TmiD?&D%QaQ^noOV8$uXDLEiX-h}w{RX2H?3wSY@w}6}HOGfrM(Df@^{v~RNl2wpo zhdf>BNJ-JfMqXMiQ<@0kOC7o?MCK_kOxAWC2F-@>2GwC6UNQy^-R1-w$3G+{&Eqi1 zM6||G&4#T%kl9y+fSf0f(JUP6L-o}vMsk+3aMo00m}c5PVMO+Xj-1ZnUoiqpvVHjb zd&qS*-2%9!xt4k}vS6}RGwF6cQY{#BPP9HmM$QI$-D!{H{tQgLE5L*#0gv){FLurf zEwp6X2b;1aQuID-W?1KF2L}(A)zHprXUz{syon(~@9NIi{Wr)a;ddV+^k31^kD+b^ zJbsaia_ENZHEeu63l%;HO*NS+o7NxlRPiQHf?tSc(GT)+qe;?`v_GvqSodMuadF2G zKus8_0dn``B-$QELKie>a`2Ndvc%@=QLNOg&AlBHn!cIF#xBjzVG?L%9uGt?j mU02cmleofpD{1p023ic-h)`?vXkG-G_x}SPH80r%;t&Akk*m%C literal 0 HcmV?d00001 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_"; +}