-
Notifications
You must be signed in to change notification settings - Fork 2.8k
perf(endpoint): optimize ProviderSpecific to use map for O(1) access #5814
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
perf(endpoint): optimize ProviderSpecific to use map for O(1) access #5814
Conversation
|
Hi @u-kai. Thanks for your PR. I'm waiting for a kubernetes-sigs member to verify that this patch is reasonable to test. If it is, they should reply with Once the patch is verified, the new status will be reflected by the I understand the commands that are listed here. Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. |
apis/v1alpha1/dnsendpoint.go
Outdated
| // DNSEndpointSpec defines the desired state of DNSEndpoint | ||
| type DNSEndpointSpec struct { | ||
| Endpoints []*endpoint.Endpoint `json:"endpoints,omitempty"` | ||
| Endpoints []*Endpoint `json:"endpoints"` |
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.
As you can see, the path is apis/v1alpha1. Moving Endpoints under this folder, means we are doing versioning for Endpoint object. Not against, but not sure if this is the right approach. As now we most likely going to have v1alpha.Endpoint....
I have no solution, not an easy one though
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.
Thanks for raising this — you’re right that moving Endpoint under apis/v1alpha1 makes it part of the public API surface and therefore versioned.
A few clarifications on intent and impact:
Intentional separation:
We’re explicitly decoupling the CRD type (apis/v1alpha1.Endpoint) from the internal type (endpoint.Endpoint).
This lets us keep the CRD schema stable while giving us freedom to optimize the internal representation (e.g., map-based ProviderSpecific) without leaking those changes into the API.
No user-facing change:
The CRD schema shape for Endpoints remains the same from a user point of view; we only replaced the reference to the internal type with the versioned API type.
On future versions (v1, etc.):
If v1’s Endpoint is identical to v1alpha1, we can avoid extra helpers by using a type alias or a shared converter.
If it diverges, we’ll add a thin conversion layer and still keep the internals map cleanly.
Typically the controller consumes one served version; the apiserver handles storage-version normalization.
Maintenance cost:
We’re aware this assigns versioning responsibility to Endpoint, but it also prevents tight coupling to internals and reduces the risk for future internal refactors — or at least, we believe it can reduce such risks.
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.
Have you generated CRDs, are they still the same or there is diff?
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.
Sorry, I didn't handle this properly initially.
I found that the Endpoints field should have the omitempty tag.
After adding omitempty to the endpoints field and regenerating the CRDs, there are no diffs.
Everything is now in the proper state.
|
/ok-to-test |
a2c62d3 to
a2c0d29
Compare
a2c0d29 to
921fb70
Compare
endpoint/endpoint.go
Outdated
| func (ps ProviderSpecific) String() string { | ||
| if len(ps) == 0 { | ||
| return "[]" | ||
| } | ||
| // Collect and sort keys for stable output. | ||
| keys := make([]string, 0, len(ps)) | ||
| for k := range ps { | ||
| keys = append(keys, k) | ||
| } | ||
| sort.Strings(keys) | ||
| // Build entries like "{key value}" preserving stable order. | ||
| b := strings.Builder{} | ||
| b.WriteByte('[') | ||
| for i, k := range keys { | ||
| if i > 0 { | ||
| b.WriteByte(' ') | ||
| } | ||
| b.WriteByte('{') | ||
| b.WriteString(k) | ||
| b.WriteByte(' ') | ||
| b.WriteString(ps[k]) | ||
| b.WriteByte('}') | ||
| } | ||
| b.WriteByte(']') | ||
| return b.String() | ||
| } |
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.
I'm not sure if we want to maintain code that mimic the default output from fmt?
I would prefer to keep the ProviderSpecificProperty struct (but package-private), build a slice of it from ProviderSpecific and use fmt.Sprintf().
Performance wise your code is faster though.
@ivankatliarchuk wdyt ?
edit:
Something like that:
func (ps ProviderSpecific) String() string {
data := make([]providerSpecificProperty, 0, len(ps))
for k, v := range ps {
data = append(data, providerSpecificProperty{Name: k, Value: v})
}
slices.SortFunc(data, func(a, b providerSpecificProperty) int {
return strings.Compare(a.Name, b.Name)
})
return fmt.Sprint(data)
}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.
Not too shure what this method is for.
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.
It's to keep the log output identical. But that's mostly for debug logs.
external-dns/endpoint/endpoint.go
Line 383 in 7792e78
| log.Debugf(`Skipping endpoint %v because of missing owner label (required: "%s")`, ep, ownerID) |
But there is some info logs too:
external-dns/registry/dynamodb.go
Line 305 in abdf8bb
| log.Infof("Skipping endpoint %v because owner does not match", ep) |
| log.WithField("endpoint", ep).Debugf("Skipping endpoint because all targets were filtered out") |
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.
@ivankatliarchuk
As @vflaux mentioned, this logic is to maintain compatibility of the log output.
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.
it's a map, so the output should be consistent. I think I'm questioning the actual need for this method. Is formatting with %q or %v is not enough?
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.
Hi @mloiseleur. Wdyt, do we want to keep same output in logging or Map : ProviderSpecific map[key1:value1 key2:value2 key3:value3] is just enough?
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.
🤔 Changing logs output is not the goal of this PR.
Nonetheless, it changes the struct, so it can be somehow expected that it impacts log output.
The default output provided by Go on map is fine and well known in go software.
My recommendation is to use it as a start, for this PR.
We'll see with time if it's not enough and if we need to update it with specific code and/or struct.
@vflaux @ivankatliarchuk @u-kai Wdyt ? Does it make sense to you ?
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.
Thanks for the feedback.
My original motivation for adding the custom String() was to keep backward compatibility in log output — since the struct changed, I wanted to avoid surprising changes in log messages.
That said, I’m also fine with simplifying the code and just returning the default map output. It makes the code cleaner, though it will slightly change the log format. If we go this route, I think it’s worth mentioning in release notes so users are aware of the change.
If you prefer that approach, I can drop the custom String() method and just let it print the map directly.
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.
Yes, I prefer the simpler approach and drop String() method.
No problem to add a line on this in the next release notes.
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.
I've removed the String() method and related tests as suggested.
@mloiseleur @ivankatliarchuk
Please review when you have a chance 🙇
| newEp.Labels[k] = v | ||
| } | ||
| newEp.ProviderSpecific = append(endpoint.ProviderSpecific(nil), ep.ProviderSpecific...) | ||
| if ep.ProviderSpecific != nil { |
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.
Maybe add a test case for this as this is not covered?
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.
I'll address the test coverage for the if ep.ProviderSpecific !=nil branch in a separate PR.
The current tests use makeZone helper function which doesn't set ProviderSpecific, and modifying it would require updating all existing tests that depend on it.
A dedicated PR focused on test infrastructure improvements would be more appropriate to maintain consistency across the codebase.
Signed-off-by: u-kai <[email protected]>
|
@mloiseleur
All tests pass and backward compatibility is maintained. Ready for review! |
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.
Overall lgtm,. few questions left to resolve
| v4EP := ep.DeepCopy() | ||
| v4EP.Targets = v4Targets | ||
| v4EP.RecordType = endpoint.RecordTypeA | ||
| v4EP := &endpoint.Endpoint{ |
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.
Is deepcopy no longer works here? Not sure if we should be using endpoint.Endpoint in refactorings, dedicated methods more preferred, so we could incapsulate things in the future.
Why this code is required, with null checks and map copy?
if ep.Labels != nil {
v4EP.Labels = make(endpoint.Labels, len(ep.Labels))
maps.Copy(v4EP.Labels, ep.Labels)
}
if ep.ProviderSpecific != nil {
v4EP.ProviderSpecific = make(endpoint.ProviderSpecific, len(ep.ProviderSpecific))
maps.Copy(v4EP.ProviderSpecific, ep.ProviderSpecific)
}My understanding v4EP created without labels or provider specific, is this not just?
v4ep.Labels = ep.LabelsThere 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.
On DeepCopy:
This code now uses an internal Endpoint type instead of the CRD code-generated one, so we don’t have generated DeepCopy methods anymore.
I don’t think it’s worth re-implementing DeepCopy just for this use case—being explicit in the adapter keeps the conversion type-safe and makes future encapsulation easier.
On the nil checks and map copy:
A direct assignment would share the same map (Go maps are reference types), so changes to the converted value could leak back to the source.
Copying avoids aliasing and preserves nil vs empty.
| if providerSpecific.Name == key { | ||
| return providerSpecific.Value, true | ||
| } | ||
| if e.ProviderSpecific == nil { |
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.
| } | ||
|
|
||
| // DeleteProviderSpecificProperty deletes any ProviderSpecificProperty of the specified name. | ||
| func (e *Endpoint) DeleteProviderSpecificProperty(key string) { |
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.
pkg/adapter/endpoint.go
Outdated
| "sigs.k8s.io/external-dns/endpoint" | ||
| ) | ||
|
|
||
| func ToInternalEndpoint(crdEp *apiv1alpha1.Endpoint) *endpoint.Endpoint { |
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.
name crdEp to specific and method description is missing for exported method
pkg/adapter/endpoint.go
Outdated
| @@ -0,0 +1,86 @@ | |||
| /* | |||
| Copyright 2017 The Kubernetes Authors. | |||
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.
| Copyright 2017 The Kubernetes Authors. | |
| Copyright 2025 The Kubernetes Authors. |
pkg/adapter/endpoint.go
Outdated
| return ep | ||
| } | ||
|
|
||
| func ToInternalEndpoints(crdEps []*apiv1alpha1.Endpoint) []*endpoint.Endpoint { |
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.
same as other method
| newEp.ProviderSpecific = append(endpoint.ProviderSpecific(nil), ep.ProviderSpecific...) | ||
| if ep.ProviderSpecific != nil { | ||
| newEp.ProviderSpecific = make(endpoint.ProviderSpecific) | ||
| maps.Copy(newEp.ProviderSpecific, ep.ProviderSpecific) |
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.
is the map copy even required, why not a direct assignment?
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.
On the map copy: direct assignment would just alias the underlying map, since maps in Go are reference types.
That would mean v4EP and ep share the same backing map, and any mutation on one would affect the other.
To avoid this aliasing we explicitly copy into a new map, so the converted object is fully independent.
This struct is used in a variety of ways in tests, and we wanted to minimize any chance of shared state leaking between instances.
Making explicit copies ensures data isolation and makes test behavior more predictable.
| w.WriteHeader(http.StatusOK) | ||
| if err := json.NewEncoder(w).Encode(records); err != nil { | ||
| apiRecords := adapter.ToAPIEndpoints(records) | ||
| if err := json.NewEncoder(w).Encode(apiRecords); err != nil { |
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.
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.
Since we are only encoding valid types into an in-memory buffer, this call cannot realistically fail.
Because the failure path is not reproducible in this context, we are intentionally not covering it with tests.
| log.Errorf("Failed to call adjust endpoints: %v", err) | ||
| w.WriteHeader(http.StatusInternalServerError) | ||
| } | ||
| pve = adapter.ToAPIEndpoints(endpoints) |
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.
pkg/adapter/endpoint.go
Outdated
| "sigs.k8s.io/external-dns/endpoint" | ||
| ) | ||
|
|
||
| func ToInternalEndpoint(crdEp *apiv1alpha1.Endpoint) *endpoint.Endpoint { |
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 name ToInternalEndpoint not necessary is clear engough. external-dns Endpoint is not internal, as is exported. I do get the meaning probably slighly different here.
Maybe ToAPI and FromApi or ToVersionedApi and FromVersionedApi
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here.
Needs approval from an approver in each of these files:
Approvers can indicate their approval by writing |
Signed-off-by: u-kai <[email protected]>
Signed-off-by: u-kai <[email protected]>
|
@ivankatliarchuk |
|
Overall lgtm. Need to find some time to execute few smoke tests on my side. |
|
@ivankatliarchuk |
Pull Request Test Coverage Report for Build 18125384109Details
💛 - Coveralls |
|
Yeap. Sry incorrect branch |
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.
This PR tries to speedup things, but there was nothing shown that:
- it's an actual problem at all
- how much faster or slower we will be by applying this gigantic risky change.
|
Let me clarify the motivation:
|
|
/close Please no ai for cv driven work. |
|
@szuecs: Closed this PR. In response to this:
Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. |
|
@szuecs @ivankatliarchuk @mloiseleur @vflaux |
|
I'll work on benchmark as well. May take few month |
|
Thanks @u-kai for your work! I think you could create a pr with the benchmark and then we can use that for testing optimizations that someone will come up with. |






What does it do ?
This PR optimizes the
ProviderSpecificfield in theEndpointstruct by changing it from a slice-based implementation ([]ProviderSpecificProperty) to a map-based implementation (map[string]string).This change improves property access performance from O(n) linear search to O(1) constant-time lookups while maintaining full backward compatibility for CRD users.
Motivation
The current slice-based
ProviderSpecificimplementation creates significant performance bottlenecks in large-scale environments.This optimization was identified as a prerequisite for addressing the performance concerns raised in PR #5799.
Before implementing provider-specific property warnings, the underlying data structure needed to be optimized to prevent the warnings themselves from becoming a performance bottleneck.
More