Skip to content

Make route prefix map matchers able to retry#38986

Merged
ravenblackx merged 18 commits into
envoyproxy:mainfrom
ravenblackx:trie_routes
May 29, 2025
Merged

Make route prefix map matchers able to retry#38986
ravenblackx merged 18 commits into
envoyproxy:mainfrom
ravenblackx:trie_routes

Conversation

@ravenblackx

@ravenblackx ravenblackx commented Apr 1, 2025

Copy link
Copy Markdown
Contributor

Commit Message: Make route prefix map matchers able to retry
Additional Description: The concept of this change is that the current behavior is quite surprising; I believe what happens after this change is what a user would typically expect the prefix matcher behavior to be, but unfortunately changing it in-place would be a behavior change now that the existing behavior is established, so this needs a runtime guard.

The difference with this version of the prefix match map is that the on_no_match field still runs if there was a subtree matcher that didn't match, whereas the old one requires each subtree to have its own on_no_match, resulting in a huge amount of duplicate config code for our use-case.

Also, before falling back to the on_no_match, this variant tries any also-matching shorter prefixes, e.g. a prefix match map with retries like

"/banana": subtree_a,
"/ban": subtree_b
"/": subtree_c
on_no_match

With a request path /banana/is/a/fruit would first try subtree_a, and if conditions in there don't return a match, would try subtree_b, then again to subtree_c, then finally the on_no_match action.

Risk Level: A little; runtime guard allows reverting.
Testing: Increased testing for existing config to ensure current [maybe unwanted] behavior, added tests for new behavior.
Docs Changes: relnotes only.
Release Notes: Yes.
Platform Specific Features: n/a

@ravenblackx
ravenblackx marked this pull request as ready for review April 1, 2025 22:26
@repokitteh-read-only

Copy link
Copy Markdown

As a reminder, PRs marked as draft will not be automatically assigned reviewers,
or be handled by maintainer-oncall triage.

Please mark your PR as ready when you want it to be reviewed!

🐱

Caused by: #38986 was opened by ravenblackx.

see: more, trace.

@repokitteh-read-only repokitteh-read-only Bot added api deps Approval required for changes to Envoy's external dependencies labels Apr 1, 2025
@repokitteh-read-only

Copy link
Copy Markdown

CC @envoyproxy/api-shepherds: Your approval is needed for changes made to (api/envoy/|docs/root/api-docs/).
envoyproxy/api-shepherds assignee is @markdroth
CC @envoyproxy/api-watchers: FYI only for changes made to (api/envoy/|docs/root/api-docs/).
CC @envoyproxy/dependency-shepherds: Your approval is needed for changes made to (bazel/.*repos.*\.bzl)|(bazel/dependency_imports\.bzl)|(api/bazel/.*\.bzl)|(.*/requirements\.txt)|(.*\.patch).
envoyproxy/dependency-shepherds assignee is @moderation

🐱

Caused by: #38986 was opened by ravenblackx.

see: more, trace.

@ravenblackx

Copy link
Copy Markdown
Contributor Author

/retest
(looks like just flakes)

@ravenblackx

Copy link
Copy Markdown
Contributor Author

/retest

@ravenblackx

Copy link
Copy Markdown
Contributor Author

/coverage

@repokitteh-read-only

Copy link
Copy Markdown

Coverage for this Pull Request will be rendered here:

https://storage.googleapis.com/envoy-pr/38986/coverage/index.html

The coverage results are (re-)rendered each time the CI envoy-presubmit (check linux_x64 coverage) job completes.

🐱

Caused by: a #38986 (comment) was created by @ravenblackx.

see: more, trace.

@markdroth

Copy link
Copy Markdown
Contributor

Thanks for bringing this up! I agree that the current behavior seems strange.

It seems like the behavior we really want here is that if a subtree does not find a match and does not have an on_no_match, then we consider the match in the parent to have not actually matched -- i.e., the parent should react as if it had not found that match in the first place and had never recursed into the sub-tree. However, if the sub-tree does have an on_no_match, then I think we should choose that, and at that point, the parent should consider the sub-tree to have produced a match.

I agree that for the prefix map matcher specifically, it makes sense that if a sub-tree is considered to not have matched, that we should look for other, less-specific prefix matches before defaulting to on_no_match. However, I think that's just a special case, because the general problem can show up with any type of matcher. For example, if we have a list matcher that selects a sub-tree, and that sub-tree does not find a match and does not have an on_no_match, then the parent list matcher should ideally execute its own on_no_match. So I think we need a more general solution here; simply providing a new variant of the prefix map matcher doesn't seem to fully solve the problem.

If we agree that this general principle is correct, then we have to figure out how to get to that behavior from where we are today. I see two main options:

  1. We could simply declare the current behavior to be a bug and fix it, possibly adding a runtime option as a migration tool for those that may be depending on the current behavior. This would not require any API changes, but it might make the migration harder for users. It's not clear to me how many existing users would be affected.
  2. If we don't want to break any existing behavior, then we need to add an API knob to select the new behavior. However, as I mentioned above, I think we need to solve it for all matcher types, not just adding a new variant of the prefix map matcher. Maybe we can just add a new bool field in the top-level Matcher proto that controls how this works?

I'd like input from @snowp, who initially wrote the matcher code.

Also, I am wondering if this has any overlap with #38726 from @bsurber.

@bsurber

bsurber commented Apr 3, 2025

Copy link
Copy Markdown
Contributor

This retry feature does look similar to what I had in mind when considering how to do re-entry for prefix map matchers. The largest difference is that a reentrant object would go back to the match caller, which is also capable of continuing "retries" (in this PR's parlance) from where the last match call ended.

Implementation wise, I was considering doing the re-entry/retry by tracking each matching path along the way down the trie in a separate vector of pointers (optional parameter). Then the reentrant would just have to step backwards through that vector to find the next-longest match, instead of starting at the top of the trie all over again: O(n) vs O(n^2) execution at the cost of O(n) memory & enabling further re-entry at a specified index.

@ravenblackx

ravenblackx commented Apr 3, 2025

Copy link
Copy Markdown
Contributor Author

This retry feature does look similar to what I had in mind when considering how to do re-entry for prefix map matchers. The largest difference is that a reentrant object would go back to the match caller, which is also capable of continuing "retries" (in this PR's parlance) from where the last match call ended.

I'm happy for it to be done that way - I was aiming for "minimal disruption" in my implementation, but I agree it would be nicer if it was made more generally "this is how it's supposed to work."

Implementation wise, I was considering doing the re-entry/retry by tracking each matching path along the way down the trie in a separate vector of pointers. Then the reentrant would just have to step backwards through that vector to find the next-longest match, instead of starting at the top of the trie all over again: O(n) vs O(n^2) execution at the cost of O(n) memory.

I also considered that. O(n) memory being the cost is a bit simplistic though - a vector is also another allocation, or potentially several, and O(n^2) is while technically accurate not really a sensible way to look at it when the real world case is going to be O(2n), maybe O(3n). A prefix config that's like /banana, /banan, /bana, /ban, /ba, /b, / isn't really going to happen.
If preferring a vector I would suggest using absl::InlinedVector<T, 4> to avoid extra allocations in the common cases. (3 is realistic, but it's a short-lived thing so the memory impact is negligible, 4 or even 8 would be fine.)

Happy to defer to #38726 for implementation; I can contribute a vector-returning trie lookup if that would help. (Edit: #39011)

@markdroth

Copy link
Copy Markdown
Contributor

Setting aside the question of implementation overlap with @bsurber's work, I think we still need to decide the overall API question here. The keep_matching knob that @bsurber is adding is for a use-case where we want individual rules to decide whether they will be considered a match, but I think the question being asked in this PR is more about whether we want a match that goes to a sub-tree to be considered a match if nothing in the sub-tree matches. I don't think that's something that can be configured via keep_matching, because it will apply in cases where there is no matching OnMatch field in which to look at the value of keep_matching.

@ravenblackx

Copy link
Copy Markdown
Contributor Author

because it will apply in cases where there is no matching OnMatch field in which to look at the value of keep_matching.

I think bsurber's implementation could do what we want. It would be quite a bit more verbose in the config than my model, but I'm assuming it would work to have a config like (pseudo-config):

prefix_match_tree:
  "/foo/bar":
    match_list:
       has_some_header: whatever
       on_no_match: keep_matching [null or do-nothing action]
  "/foo":
    match_list:
       has_some_other_header: whatever
       on_no_match: keep_matching [null or do-nothing action]
  on_no_match: some_default_action

@bsurber

bsurber commented Apr 7, 2025

Copy link
Copy Markdown
Contributor

I also considered that. O(n) memory being the cost is a bit simplistic though - a vector is also another allocation, or potentially several, and O(n^2) is while technically accurate not really a sensible way to look at it when the real world case is going to be O(2n), maybe O(3n). A prefix config that's like /banana, /banan, /bana, /ban, /ba, /b, / isn't really going to happen. If preferring a vector I would suggest using absl::InlinedVector<T, 4> to avoid extra allocations in the common cases. (3 is realistic, but it's a short-lived thing so the memory impact is negligible, 4 or even 8 would be fine.)

We could also avoid the vector output by adding optional parent references to the trie nodes. The reentrant would just need to track the current node & iterate up through the parents. It'd touch more code but could be preferable (requires touching the actual trie lib, not just the matchers).

@kyessenov

Copy link
Copy Markdown
Contributor

For the trie-based matcher, we actually do perform the traversal (see https://github.com/cncf/xds/blob/main/xds/type/matcher/v3/domain.proto#L39), so maybe we need to define a new kind of prefix matcher as a custom matcher?

@ravenblackx

Copy link
Copy Markdown
Contributor Author

For the trie-based matcher, we actually do perform the traversal (see https://github.com/cncf/xds/blob/main/xds/type/matcher/v3/domain.proto#L39), so maybe we need to define a new kind of prefix matcher as a custom matcher?

"The trie-based matcher" is a confusing thing to say, because prefix_map_matcher is also trie-based. Also confusing to link to that xds proto which doesn't seem to be used at all in envoy except in a fuzz test. I eventually figured out you meant trie_matcher.h does a multimatch.

Anyway, it certainly would be possible to implement this as just a custom matcher, if that's the preferred way. Bit weird since the API would be an exact duplicate of the existing API but it would have to either duplicate or wrap the existing message because custom extensions must use unique types. And mostly duplicate the code. And every time there's a custom extension point it makes the documentation painful to navigate. But if that's what it takes to get something useable I'm happy to go that way.

@kyessenov

kyessenov commented Apr 11, 2025

Copy link
Copy Markdown
Contributor

For the trie-based matcher, we actually do perform the traversal (see https://github.com/cncf/xds/blob/main/xds/type/matcher/v3/domain.proto#L39), so maybe we need to define a new kind of prefix matcher as a custom matcher?

"The trie-based matcher" is a confusing thing to say, because prefix_map_matcher is also trie-based. Also confusing to link to that xds proto which doesn't seem to be used at all in envoy except in a fuzz test. I eventually figured out you meant trie_matcher.h does a multimatch.

Anyway, it certainly would be possible to implement this as just a custom matcher, if that's the preferred way. Bit weird since the API would be an exact duplicate of the existing API but it would have to either duplicate or wrap the existing message because custom extensions must use unique types. And mostly duplicate the code. And every time there's a custom extension point it makes the documentation painful to navigate. But if that's what it takes to get something useable I'm happy to go that way.

Absolutely right about documentation. I don't think proto docs even cut it for the matching configuration, we need examples for specific use cases. I brought this up because we had a similar discussion when I introduced this custom matcher. The problem with the prefix matcher is that it's generic for strings, it is also used to match various authentication attributes (e.g. JWT attributes, TLS fields, etc) where it's not appropriate to have this "keep-matching" logic since Ali and Alice principals should never be confused. Trie-matching only makes sense for URIs and even then extra-care needs to be done to make sure things like /foo, /fo%23, /fÖo, /foob, /foo#, /foo?, and /foo/ are not ambiguous.

@ravenblackx

Copy link
Copy Markdown
Contributor Author

Absolutely right about documentation. I don't think proto docs even cut it for the matching configuration, we need examples for specific use cases. I brought this up because we had a similar discussion when I introduced this custom matcher. The problem with the prefix matcher is that it's generic for strings, it is also used to match various authentication attributes (e.g. JWT attributes, TLS fields, etc) where it's not appropriate to have this "keep-matching" logic since Ali and Alice principals should never be confused. Trie-matching only makes sense for URIs and even then extra-care needs to be done to make sure things like /foo, /fo%23, /fÖo, /foob, /foo#, /foo?, and /foo/ are not ambiguous.

Ah, thanks, I didn't realize it was used for other things, so yeah, that makes sense of it (though this implementation did control whether it does "keep matching" with a new flag in the config so it's not like it would be forcing unwanted behavior).

Question: are you saying that looking back up in the case of a specific match being tagged with the "keep_matching" property (as in #38726) shouldn't cause prefix_map_matcher to "look up"? (If yes then I should implement a custom extension, but if no then I should just pay attention to bsurber's solution. Or if you're saying bsurber's solution is going to encounter a lot of pushback then that too means I should do it as a custom extension.)

@phlax

phlax commented Apr 17, 2025

Copy link
Copy Markdown
Member

/wait-any

for further consideration

@markdroth

Copy link
Copy Markdown
Contributor

LGTM from an API perspective (even though this doesn't actually require API approval).

Comment thread changelogs/current.yaml Outdated
- area: prefix_match_map
change: |
:ref:`prefix_match_map <envoy_v3_api_field_config.common.matcher.v3.Matcher.MatcherTree.prefix_match_map>`
now continues to search for a match if the first subtree match

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.

continue to search for a match with shorter prefix if the longest match does not find an action. While it's technically a map, it's a list in protobuf, so let's be explicit about ordering.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done (though I said "a longer match" rather than "the longest match" because it also does it for second-longest etc.)

}
ASSERT(result->matcher_);
typename MatchTree<DataType>::MatchResult match = result->matcher_->match(data);
if (match.match_state_ != MatchState::MatchComplete || match.on_match_.has_value()) {

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.

So this will not work with partial match, since we can no longer backtrack when an incomplete match gets returned here? Does this actually happen in Envoy when a MatchComplete is not returned for a prefix map?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If a ListMatcher receives something other than a MatchComplete then yes, this same thing happens (I think this is correct, because you shouldn't resolve to a different result if you don't know yet that your first result didn't match.)

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.

Yes, you shouldn't, but if say the match later fails when the data arrives, there'd be no way to backtrack to a shorter prefix in the original tree. So partial match seems to be flawed to be "longest/first always wins". Is there some bug filed about this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think one of us doesn't understand what's happening here, and I don't really know which, maybe both! Can you elaborate on "the match later fails when the data arrives"? My understanding is that when that happens either the whole lookup will run again from scratch when more data is available, or it will be aborted entirely. I can't see a way it would result in a wrong outcome. There's no continuation data with a MatchState::UnableToMatch response, so I'm pretty sure there's no path to "resolve to the wrong outcome".

But also I don't really understand how you would even provoke a MatchState::UnableToMatch response in production, nor what the behavior is when that result occurs, so it's very possible that I'm missing something. Also I don't know what you mean by "partial match", there only seems to be a match, a miss, or UnableToMatch, what is a partial match?

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.

It's worth double checking, since I'm not certain either! An example of this happening is matching on SNI with prefix match map, and then in the inner matcher, matching on a header (when that header is not yet received, that's possible in some HCM configuration). I see that evaluateMatch short-cuts the path when it's UnableToMatch so I think you're right, it will run the tree matcher from scratch when the data is received (which is worse performance-wise, but correct).

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.

Actually, I still don't get this logic -> it's not returning match_state_ upwards to the caller?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You were right, this was a (pre-existing) issue. I think it may still be an issue that other matchers also don't bubble up UnableToMatch from submatchers (may be resolved after #38726). We probably should have a mandatory common test suite for matcher implementations rather than implementing these tests individually, to ensure that this sort of thing doesn't happen again. But that's for future cleanup, not for this PR.

Comment thread source/common/matcher/prefix_map_matcher.h Outdated
@kyessenov

Copy link
Copy Markdown
Contributor

/wait

@ravenblackx

Copy link
Copy Markdown
Contributor Author

/retest

@ravenblackx
ravenblackx requested a review from kyessenov May 22, 2025 18:22
kyessenov
kyessenov previously approved these changes May 23, 2025

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

LGTM, partial match correctness needs to be investigated later.

@mattklein123 mattklein123 removed their assignment May 25, 2025
@ravenblackx

Copy link
Copy Markdown
Contributor Author

/retest

@ravenblackx
ravenblackx merged commit e17a126 into envoyproxy:main May 29, 2025
@ravenblackx
ravenblackx deleted the trie_routes branch May 29, 2025 18:25
phlax pushed a commit that referenced this pull request May 29, 2025
Commit Message: Fix forward on a bad merge
Additional Description: Broke a test by adding it and changing the test
format at the same time. (#38986 + #39603)
Risk Level: None
Testing: Test-only
Docs Changes: n/a
Release Notes: n/a
Platform Specific Features: n/a

Signed-off-by: Raven Black <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api deps Approval required for changes to Envoy's external dependencies

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants