-
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
base: master
Are you sure you want to change the base?
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.
I dunno. When I merge with master I just do |
Was it tested on real environment? The potential risk here - it may keep try to recreate/update same endpoints over and over again, due some of the changes. |
Alright, then I’ll resolve conflicts with --no-rebase for now.
I tested the following scenario and didn’t see any issues: Ran with v0.19.0 using DNSEndpoint and Ingress as sources. Then switched to the version with this change applied and confirmed it kept working fine. After that, modified the record contents and verified the DNS records were updated accordingly. Of course, it’s not realistic to cover every possible case. That said, since this change doesn’t alter any external |
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 code LGTM.
🤔 The last thing we should double check / confirm: does it have impact on webhook providers ?
@mloiseleur |
Signed-off-by: u-kai <[email protected]>
Signed-off-by: u-kai <[email protected]>
…version Signed-off-by: u-kai <[email protected]>
…sion Signed-off-by: u-kai <[email protected]>
…lity Signed-off-by: u-kai <[email protected]>
Signed-off-by: u-kai <[email protected]>
Signed-off-by: u-kai <[email protected]>
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.Labels
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 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. |
PR needs rebase. 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. |
What does it do ?
This PR optimizes the
ProviderSpecific
field in theEndpoint
struct 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
ProviderSpecific
implementation 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