-
Notifications
You must be signed in to change notification settings - Fork 3.7k
[improve][metadata] Add partition key resolver support for Oxia index scans #26148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -277,8 +277,9 @@ protected CompletableFuture<Void> storeScanByIndex( | |
| // (it's the scan-and-filter compat-path predicate). | ||
| CompletableFuture<Void> chain = CompletableFuture.completedFuture(null); | ||
| for (String key : primaryKeys) { | ||
| Set<Option> getOpts = OptionsHelper.withResolvedPartitionKey(opts, key); | ||
| chain = chain | ||
| .thenCompose(__ -> storeGet(key, opts)) | ||
| .thenCompose(__ -> storeGet(key, getOpts)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The resolver is evaluated eagerly during the chain assembly. Because the first thenCompose is attached to an already-completed future, an earlier storeGet may already be in flight when a later resolver throws. The outer stage then calls onError, but the detached get can still complete and invoke onNext afterward, which violates scanByIndex’s terminal-callback contract. Resolve the partition key inside the sequential thenCompose stage and add a resolver-failure ordering test.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. The partition-key resolution now happens inside the sequential Added a regression test that holds the first read pending and makes the second resolver fail. It verifies that the scan remains pending before the first read completes, then observes |
||
| .thenAccept(opt -> opt.ifPresent(consumer::onNext)); | ||
| } | ||
| return chain; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,16 +21,20 @@ | |
| import static org.testng.Assert.assertEquals; | ||
| import static org.testng.Assert.assertFalse; | ||
| import static org.testng.Assert.assertTrue; | ||
| import static org.testng.Assert.fail; | ||
| import io.oxia.testcontainers.OxiaContainer; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
| import java.util.Set; | ||
| import lombok.Cleanup; | ||
| import org.apache.pulsar.metadata.api.GetResult; | ||
| import org.apache.pulsar.metadata.api.MetadataStore; | ||
| import org.apache.pulsar.metadata.api.MetadataStoreConfig; | ||
| import org.apache.pulsar.metadata.api.MetadataStoreFactory; | ||
| import org.apache.pulsar.metadata.api.Option; | ||
| import org.apache.pulsar.metadata.api.ScanConsumer; | ||
| import org.testng.annotations.AfterClass; | ||
| import org.testng.annotations.BeforeClass; | ||
| import org.testng.annotations.Test; | ||
|
|
@@ -114,4 +118,45 @@ public void getChildrenWithPartitionKey() throws Exception { | |
| assertTrue(children.containsAll(List.of("a", "b")), | ||
| "expected children a and b, got: " + children); | ||
| } | ||
|
|
||
| @Test | ||
| public void scanByIndexUsesPartitionKeyResolverForPrimaryReads() throws Exception { | ||
| @Cleanup | ||
| MetadataStore store = newStore(); | ||
|
|
||
| String parent = "/partition-key-index-scan-" + System.nanoTime(); | ||
| String indexName = "idx:partition-key-resolver-" + System.nanoTime(); | ||
| String indexKey = "match"; | ||
| RoutedKey routedKey = createIndexedKeyOnlyVisibleWithPartitionKey(store, parent, indexName, indexKey); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This helper may try multiple candidate records, but every unsuccessful candidate is deleted and it returns immediately after finding the first cross-shard record. Therefore only one indexed record exists when scanByIndex runs. The motivating case is a single scan containing primary records with different partition keys. Could the test retain at least two cross-shard records with distinct partition keys and assert that both are returned? This would catch an implementation that accidentally resolves or caches one partition key per scan rather than per result.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated. The integration test now retains two cross-shard indexed records with distinct partition keys and asserts that both primary paths are returned by the scan. This exercises per-result resolution rather than a single scan-level routing key. The focused unit and multi-shard Oxia integration tests pass. |
||
|
|
||
| List<GetResult> results = new ArrayList<>(); | ||
| Set<Option> opts = Set.of(new Option.PartitionKeyResolver( | ||
| path -> path.substring(path.lastIndexOf('/') + 1, path.lastIndexOf("-record")))); | ||
| store.scanByIndex(parent, indexName, indexKey, indexKey, __ -> true, | ||
| ScanConsumer.collectInto(results), opts).get(); | ||
|
|
||
| assertEquals(results.stream().map(result -> result.getStat().getPath()).toList(), | ||
| List.of(routedKey.path()), | ||
| "scanByIndex should use the resolver to route follow-up primary-key reads"); | ||
| } | ||
|
|
||
| private RoutedKey createIndexedKeyOnlyVisibleWithPartitionKey( | ||
| MetadataStore store, String parent, String indexName, String indexKey) throws Exception { | ||
| for (int i = 0; i < 100; i++) { | ||
| String partitionKey = "index-route-" + i + "-" + System.nanoTime(); | ||
| String path = parent + "/" + partitionKey + "-record"; | ||
| Set<Option> options = Set.of(new Option.PartitionKey(partitionKey), | ||
| new Option.SecondaryIndex(indexName, indexKey)); | ||
| store.put(path, "value".getBytes(StandardCharsets.UTF_8), Optional.empty(), options).get(); | ||
| if (store.get(path).get().isEmpty()) { | ||
| return new RoutedKey(path, options); | ||
| } | ||
| store.deleteIfExists(path, Optional.empty(), options).get(); | ||
| } | ||
| fail("Could not find an indexed key whose PartitionKey routes to a different shard from its path"); | ||
| return null; | ||
| } | ||
|
|
||
| private record RoutedKey(String path, Set<Option> options) { | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PartitionKeyResolver explicitly allows returning null, but the current behavior then performs a lookup without a partition key. If the indexed record was originally stored with a custom partition key, this lookup can access a different shard and return an empty result—silently dropping the indexed data while the scan reports success. Could we either cause the scan to fail when routing cannot be resolved or, alternatively, clearly document and test the precise conditions under which the unpartitioned fallback is safe?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
PartitionKeyResolvernow requires a non-null key for every scanned primary path. Returningnullfails the scan with anIllegalStateExceptioninstead of falling back to an unpartitioned read, preventing a silent cross-shard miss.Added a test that verifies the error callback and confirms that no primary read is issued when the resolver cannot provide a key.