Skip to content

[fix][meta] Find the records written with a partition key in Oxia secondary-index scans - #26920

Merged
merlimat merged 3 commits into
apache:masterfrom
merlimat:mmerli/oxia-scan-by-index-partition-key
Oct 15, 2026
Merged

merlimat merged 3 commits into
apache:masterfrom
merlimat:mmerli/oxia-scan-by-index-partition-key

Conversation

@merlimat

Copy link
Copy Markdown
Contributor

Motivation

With an Oxia metadata store whose namespace has more than one shard, scanByIndex drops the records written with Option.PartitionKey. Every PIP-473 transaction record has a partition key (its transaction or its segment), and TxnMetadataStore scans them through secondary indexes without one. So on a sharded Oxia, these all work from incomplete lists:

  • transaction buffer and pending-ack recovery;
  • the coordinator's end-transaction fan-out, and its timeout and GC sweeps;
  • the segment and subscription cleanups.

The cause is in OxiaMetadataStore.storeScanByIndex:

  • It lists the matching keys with client.list(from, to, UseIndex(...)), which reads every shard.
  • It then reads each record with storeGet(key, opts). Without a partition key in opts, the Oxia client sends that get to the shard of the record's key. A record written with a partition key lives on the shard of its partition key.
  • So each record whose key and partition key hash to different shards is listed, not found, and skipped.

MetadataStoreSecondaryIndexTest runs on a single-shard Oxia without partition keys, and OxiaPartitionKeyTest (3 shards) only reads records back with the partition key they were written with, so neither caught this.

Example

On a 3-shard Oxia (oxia/oxia:nightly-beta), on master:

for (int i = 0; i < 30; i++) {
    store.put(prefix + "/r-" + i, value, Optional.of(-1L),
            Set.of(new Option.PartitionKey("group-" + i), new Option.SecondaryIndex("by-tag", "tag"))).get();
}
store.scanByIndex(prefix, "by-tag", "tag", "tag", r -> true, ScanConsumer.collectInto(results)).get();
// results: 8 to 13 of the 30 records, depending on the run
client.list(scopedFrom, scopedTo, listOpts)                       // every shard
        .thenCompose(primaryKeys -> {
            ...
            chain = chain.thenCompose(__ -> storeGet(key, opts))  // the shard of `key`
                    .thenAccept(opt -> opt.ifPresent(consumer::onNext));

With the Oxia CLI on the same data, the index list returns all 30 keys. A get without the partition key finds 8 records; with it, all 30.

Through TxnMetadataStore, with 30 transactions that each wrote and acked on one segment (15 open, 15 committed):

Query Found
listWritesBySegment 12 of 30
listAcksBySegmentSubscription 8 of 30
listOpenByDeadlineRange 3 of 15
listFinalizedByStateAndTimeRange 4 of 15

scanAbortedTxns found 9 of 30, and in one run listOpsByTxn returned none of the transaction's ops.

Modifications

  • OxiaMetadataStore.storeScanByIndex reads the records in the index scan with client.rangeScan(from, to, consumer, {UseIndex(indexName), [PartitionKey]}). Each shard returns the matching records it holds, with their key, value and version. The bounds are unchanged. The server has returned the primary key with index range-scan results since secondary indexes were added (Secondary indexes implementation oxia-db/oxia#544).
  • It skips the entries of records deleted after the scan read their index entry:
    • The server walks the index from a snapshot but reads each record from the latest state, so it returns such a record as not found, with no version or value.
    • The Java client turns that into a GetResult with an empty value and a zero Version. In a test that deleted indexed records while scanning them, 1,529 of the 16,753 entries the client returned were like this.
    • Before this change, the per-key get skipped them by returning nothing.
  • A consumer that throws stops the scan (onNext returns false, and the client then calls onCompleted), and the scan fails with that exception, as before.
  • TxnMetadataStore.listOpsByTxn and scanAbortedTxns pass the partition key their records share (the transaction, the segment), so these scans read one shard instead of all of them.
  • Across shards, records now come in shard order instead of sorted by key. scanByIndex doesn't promise an order, and no caller relies on one.

Verifying this change

  • OxiaPartitionKeyTest (3 shards): a scan across 30 partition keys returns every record (8 to 13 of 30 before the change), and a scan scoped to one partition key returns that partition's records.
  • OxiaMetadataStoreScanByIndexTest (mocked Oxia client): the scan passes the index and the partition key to rangeScan, skips a not-found entry, and fails with a consumer's exception.
  • TxnMetadataStoreShardedOxiaTest (3 shards): each of the six index queries returns the records of all 30 transactions. Without the store change, the four cross-partition queries fail and the two partition-keyed ones pass. Without either change, all six fail.
  • MetadataStoreSecondaryIndexTest (60 tests, every provider), MetadataCacheSecondaryIndexTest, TxnMetadataStoreTest, MetadataTransactionBufferTest, MetadataPendingAckStoreTest, TransactionCoordinatorV5Test, ScalableTopicPropertyIndexTest and V5Transaction* pass. quickCheck passes.

Does this pull request potentially affect one of the following parts:

If the box was checked, please highlight the changes

  • Dependencies (add or upgrade a dependency)
  • The public API
  • The schema
  • The default values of configurations
  • The threading model
  • The binary protocol
  • The REST endpoints
  • The admin CLI options
  • The metrics
  • Anything that affects deployment

…ondary-index scans

OxiaMetadataStore.storeScanByIndex listed the index with client.list(..., UseIndex), which reads every
shard, then fetched each listed key with storeGet(key, opts). Without a partition key in opts, that get
goes to the shard of the record's key, but a record written with Option.PartitionKey lives on the shard
of its partition key. On an Oxia namespace with more than one shard, the records whose partition key and
key hash to different shards were listed, not found, and dropped. Every PIP-473 transaction record has a
partition key and TxnMetadataStore scans them without one: on a 3-shard Oxia, listWritesBySegment
returned 12 of 30 write records and listOpenByDeadlineRange 3 of 15 open transactions.

Read the records in the scan instead: client.rangeScan(..., UseIndex) returns the value, version and key
of each record from the shard that holds it. Skip the entries of records deleted after the scan read
their index entry, which Oxia returns as not found, without a version or a value. A consumer exception
stops the scan and fails it with that exception, as before.

TxnMetadataStore.listOpsByTxn and scanAbortedTxns now pass the partition key their records share (the
transaction, the segment), so these scans read one shard instead of all of them.

@codelipenghui codelipenghui left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The native indexed range scan looks like the right fix for missing partition-keyed records: returning values from the shards holding them avoids re-routing each primary key without its original partition key.

One compatibility question about the ordering statement in the description, based on head 1cc87bcfcb9d65746890caf8046a1ac1db81b7e2:

  • ScanConsumer's Javadoc explicitly covers both scanChildren and scanByIndex and says: “The store invokes … onNext for each record (in key order).”
  • In Oxia client 0.9.5, list without a partition key uses internalListMultiShards, which combines the returned primary keys and calls list.sort(CompareWithSlash.INSTANCE). The previous Pulsar implementation chained its reads/callbacks sequentially, preserving that order among the records it actually returned.
  • The native range scan's multi-shard consumer forwards serialized arrivals without globally sorting them, so this changes observable ordering on that path.

This is narrower than saying every old scan guaranteed primary-key order: an explicit partition key takes the single-shard list path without that client-side sort, and the Javadoc does not distinguish primary-key order from secondary-index order.

Could we clarify the intended ordering contract and reconcile the Javadoc with the implementation? If unordered indexed scans are intended, please make that explicit and address compatibility with existing consumers; if ordering is required, it needs to be preserved. I am not suggesting unconditional buffering/sorting of all returned values without considering the memory and streaming-latency cost.

This is based on source inspection only; no tests were run, and no consumer regression has been reproduced.

…he filtered topic listing

ScanConsumer said that a scan returns its records in key order. On an Oxia namespace with several shards,
the scans pass on the records of each shard as they arrive: scanChildren since it was added, and
scanByIndex now that it reads the records in the index range scan. Say that the records come in no
particular order, in ScanConsumer and in MetadataStore.scanByIndex.

No consumer of scanByIndex depends on the order: the transaction ones collect sets, maps or flags, or act
on each record on its own. The order a user sees is the one of the property-filtered scalable topic
listing of the admin API, which returned the records in the order of the scan. Sort it by path, the order
of the unfiltered listing on every store.

@codelipenghui codelipenghui left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head 358e23227196f5edebf798f200bf26fab71c231f.

The native indexed scan fixes the partition-key routing problem, and the updated ordering documentation plus caller-side topic-list sorting addresses my earlier concern. I am requesting changes for one introduced timeout/liveness regression, detailed inline.

Validation: all 158 focused tests passed (83 metadata and 75 broker tests). An isolated A/B probe using the actual pre-PR adapter reproduced the original omission (8/30 records before, 30/30 after). However, after stopping the initialized three-shard Oxia server with a configured one-second request timeout, the pre-PR adapter failed after 1,007 ms with one error callback, while this head remained pending after 5,006 ms with no terminal callback.

Please preserve a bounded retry deadline for index scans and add regression coverage for persistent retryable shard failures. No additional actionable findings were identified.

The Oxia client retries the range scan of a shard it can't reach with no deadline, and keeps to itself the
failures of the attempts that got no response, so a scan of an unavailable shard never ended. list(), which
the scan replaced, fails after the request timeout of the client.

IndexScan fails the scan with a TimeoutException when it doesn't end within the request timeout of the
client, the one it was built with so that the client config file can set it. The consumer and the future
get exactly one terminal callback, and the scan stops at the next record the client passes on after that.
The timeout is cancelled when the scan ends.

@codelipenghui codelipenghui left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the follow-up at b1ee6cbba0dd333fbea9df175df6afb7915f8f10. The previous timeout finding is resolved: index scans now use the configured deadline, select exactly one terminal callback, and suppress late callbacks.

All 160 focused tests passed (85 metadata and 75 broker tests). An independent probe through the production factory/config-file path verified that a one-second timeout fails the outage scan after 1,002 ms with exactly one error callback; the healthy three-shard scan still returns all 30 records.

No actionable findings identified within the reviewed scope. Approving the code changes; required CI checks should pass before merging.

@merlimat
merlimat merged commit 013c8b0 into apache:master Oct 15, 2026
80 of 83 checks passed
@merlimat
merlimat deleted the mmerli/oxia-scan-by-index-partition-key branch October 15, 2026 15:58
lhotari pushed a commit that referenced this pull request Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants