Skip to content

#2130 MetadataTransfer: single pass over the metadata for wildcard keys - #2133

Open
GGraziadei wants to merge 1 commit into
apache:mainfrom
GGraziadei:perf/metadata-transfer-single-pass
Open

#2130 MetadataTransfer: single pass over the metadata for wildcard keys#2133
GGraziadei wants to merge 1 commit into
apache:mainfrom
GGraziadei:perf/metadata-transfer-single-pass

Conversation

@GGraziadei

@GGraziadei GGraziadei commented Sep 6, 2026

Copy link
Copy Markdown
Member

Fixes #2130.

MetadataTransfer.filter() runs for every outlink. For each wildcard key (e.g. cookie.*) it called Metadata.keySet(prefix), which streams over all the metadata keys and collects a new Set, then copied each matching key through getValues()/setValues() with key normalisation on both sides.

Change

The configured keys are compiled once into exact keys and wildcard prefixes (cached per set, rebuilt if a subclass modifies the set), and the matching entries are copied in a single pass over the metadata map. Value arrays are shared as before, keys are already normalised.

Micro-benchmark on a 12-key metadata with 3 wildcards and 2 exact keys: ~780 ns to ~265 ns per outlink.

Tests

MetadataTransferTest gains a case checking that wildcard prefixes are matched case-insensitively and selectively (Cookie.* matches cookie.id but not cookies), existing cases unchanged.


For all changes

  • Is there a issue associated with this PR? Is it referenced in the commit message?
  • Does your PR title start with #XXXX where XXXX is the issue number you are trying to resolve?
  • Has your PR been rebased against the latest commit within the target branch (typically main)?
  • Is your initial contribution a single, squashed commit?
  • Is the code properly formatted with mvn git-code-format:format-code -Dgcf.globPattern="**/*" -Dskip.format.code=false?

For code changes

  • Have you ensured that the full suite of tests is executed via mvn clean verify?
  • Have you written or updated unit tests to verify your changes?
  • If adding new dependencies to the code, are these dependencies licensed in a way that is compatible for inclusion under ASF 2.0? (no new dependencies)
  • If applicable, have you updated the LICENSE file, including the main LICENSE file? (not applicable)
  • If applicable, have you updated the NOTICE file, including the main NOTICE file? (not applicable)

@rzo1
rzo1 requested review from dpol1, jnioche and sigee September 6, 2026 13:45
@rzo1 rzo1 added this to the 4.0.0 milestone Sep 6, 2026
@rzo1
rzo1 requested a review from mvolikas September 6, 2026 14:09

@rzo1 rzo1 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.

Currently I am short on time, so first round AI-based review)

The single-pass change itself is right. Metadata.keySet(prefix) builds a stream and collects a new Set per prefix per outlink, and that is worth removing.

I checked the behaviour that is easy to get wrong here, and it is preserved: the old metadata.copy() went through setValues, which drops null and zero-length arrays, and getValues returns null for a zero-length array. The new values.length > 0 / entry.getValue().length == 0 guards match that. Value arrays were shared by reference before too, so the PR description is accurate on that point.

My concern is the caching, not the single pass. See the inline comments on lines 230 and 238.

this.prefixes = prefixList.toArray(new String[0]);
}

private boolean isFor(Set<String> filter) {

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.

return sourceSet == filter && sourceSize == filter.size();

This detects a size change but not a content change of the same size. mdToTransfer is protected final Set<String> with mutable contents, so a subclass doing

mdToTransfer.remove("depth");
mdToTransfer.add("mycustom");

after the first filter() call keeps the size and leaves the compiled filter stale. Every outlink from then on carries the wrong metadata, silently.

The javadoc on line 205 says the cache is "rebuilt if the set has been modified since (e.g. by a subclass)", which is a stronger claim than the code makes good on.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in a9578fd. CompiledFilter now keeps a HashSet snapshot of the keys it was built from and isFor is snapshot.equals(filter), so a same-size content change (remove("depth"); add("mycustom")) rebuilds the compiled form. Javadoc adjusted to describe what the code does. Test testSameSizeMutationOfTransferSetIsHonoured reproduces the exact scenario and failed on the previous revision.

private volatile CompiledFilter compiledTransfer;
private volatile CompiledFilter compiledPersistOnly;

private CompiledFilter compile(Set<String> filter) {

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 two sets are only ever populated in configure(). If that stays true, none of this machinery is needed: compile once at the end of configure() into two final fields and drop the lazy cache, the two volatiles, the identity dispatch and the staleness heuristic.

That keeps the whole measured win, since the per-outlink cost you removed is the stream and Set allocation, not the compile.

If subclass mutation after configure() really has to be supported, the check needs to be sound: either key the cache on a copy of the set contents, or give subclasses an explicit invalidateCompiledFilters() to call.

As it stands the design pays for both options and is correct under neither.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I went with the second option (sound check on a copy of the contents) rather than compiling at the end of configure(), for one reason: configure() is protected and a subclass that does super.configure(conf); mdToTransfer.add("added.*"); is the natural extension point given the two protected sets. Compiling inside the base configure() would leave that subclass with a stale filter. testSubclassCanExtendTransferSetInConfigure pins that case.

The cache is now built lazily on first use and isFor is a Set.equals against the snapshot: a size check plus one hash lookup per key with cached String hashes, no allocation. The per-outlink win (the stream + intermediate Set) is unchanged.

Dropped: the two volatiles (all CompiledFilter fields are final, and the cache is idempotent), the identity dispatch and the null branch. filter(Metadata, Set) is now filter(Metadata, CompiledFilter) fed by transferFilter() / persistOnlyFilter().

CompiledFilter compiled =
filter == mdToTransfer
? compiledTransfer
: filter == mdToPersistOnly ? compiledPersistOnly : null;

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.

filter(Metadata, Set) is private and only ever called with the two fields, so this null branch is unreachable. It disappears if the compile moves into configure().

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Gone. The private filter now takes a CompiledFilter directly, obtained from transferFilter() / persistOnlyFilter(), so there is no dispatch on set identity anymore.

if (compiled.prefixes.length > 0) {
for (Map.Entry<String, String[]> entry : source.entrySet()) {
final String key = entry.getKey();
if (entry.getValue().length == 0 || target.containsKey(key)) {

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.

Unguarded dereference of entry.getValue().

Metadata(Map) wraps a caller-supplied map without validating it, so a null value array reaches this line and throws, where the old path called getValues(), got null, and skipped the key. The exact-key branch above is null-checked; this one is not.

Unlikely, but it is one != null away.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. The wildcard loop reads entry.getValue() once and skips null as well as empty arrays, matching the old getValues() behaviour. testNullValueArrayIsSkipped builds a Metadata over a map with null arrays for both an exact key and a wildcard match; it threw an NPE on the previous revision.

static class MyCustomTransferClass extends MetadataTransfer {}

@Test
void testWildcardPrefixIsCaseInsensitiveAndSelective() throws MalformedURLException {

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.

Good addition, and it pins the case-insensitive prefix behaviour.

Nothing covers the caching, which is the part carrying the risk. A test that calls getMetaForOutlink, then mutates mdToTransfer through a subclass without changing its size, then calls it again, would fail today.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added three tests: testSameSizeMutationOfTransferSetIsHonoured (remove/add keeping the size, second call must see the new keys; failed before), testSubclassCanExtendTransferSetInConfigure (subclass adds a wildcard after super.configure()), and testNullValueArrayIsSkipped. Full core verify: 447 tests, 0 failures.

…ard keys

Pre-compile the keys to transfer into exact keys and wildcard prefixes
and copy the matching entries in one pass over the metadata, instead of
building an intermediate key set per wildcard for every outlink.
Micro-benchmark on a 12-key metadata with 3 wildcards: ~780 ns to
~265 ns per outlink.

The compiled form is built lazily and keyed on a snapshot of the key
set, so a subclass editing mdToTransfer / mdToPersistOnly (in an
overridden configure() or later, even without changing the size) is
always honoured. Null value arrays in a caller-supplied map are skipped
as before.

Fixes apache#2130.
@GGraziadei
GGraziadei force-pushed the perf/metadata-transfer-single-pass branch from 44ff8ab to a9578fd Compare September 6, 2026 22:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MetadataTransfer: avoid an intermediate key set per wildcard for every outlink

2 participants