From 84a3b415b808002f636dc88361fa7aefc731cf69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:22:05 +0000 Subject: [PATCH 1/4] Initial plan From f3657492c2d4ab04d37f04ae24ea5716671c90a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:47:10 +0000 Subject: [PATCH 2/4] perf: cache parsed resource-selector PEG and skip canonical wildcard work Agent-Logs-Url: https://github.com/flanksource/duty/sessions/0e75e66d-86e1-4003-9fdf-356336804af3 Co-authored-by: moshloop <1489660+moshloop@users.noreply.github.com> --- query/resource_selector.go | 35 +++++++++++++++++++++---- query/resource_selector_cache_test.go | 37 +++++++++++++++++++++++++++ types/resource_selector_canonical.go | 31 ++++++++++++++++++++++ types/resource_selector_test.go | 14 ++++++++++ 4 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 query/resource_selector_cache_test.go diff --git a/query/resource_selector.go b/query/resource_selector.go index 36ff0af4e..bd37f53af 100644 --- a/query/resource_selector.go +++ b/query/resource_selector.go @@ -25,6 +25,13 @@ import ( "github.com/flanksource/duty/types" ) +var resourceSelectorPEGCache = cache.New(time.Hour, 2*time.Hour) + +type parsedResourceSelectorPEG struct { + queryField grammar.QueryField + flatFields []string +} + type SearchResourcesRequest struct { // Limit the number of results returned per resource type Limit int `json:"limit"` @@ -235,13 +242,12 @@ func SetResourceSelectorClause( } if peg := resourceSelector.ToPeg(false); peg != "" { - qf, err := grammar.ParsePEG(peg) + parsedPEG, err := getParsedResourceSelectorPEG(peg) if err != nil { return nil, fmt.Errorf("error parsing grammar[%s]: %w", peg, err) } - flatFields := grammar.FlatFields(qf) - searchSetAgent = slices.ContainsFunc(flatFields, func(s string) bool { + searchSetAgent = slices.ContainsFunc(parsedPEG.flatFields, func(s string) bool { field := strings.ToLower(s) if alias, ok := qm.Aliases[field]; ok { field = alias @@ -249,7 +255,7 @@ func SetResourceSelectorClause( return field == "agent_id" }) - searchSetDeleted = slices.ContainsFunc(flatFields, func(s string) bool { + searchSetDeleted = slices.ContainsFunc(parsedPEG.flatFields, func(s string) bool { field := strings.ToLower(s) if alias, ok := qm.Aliases[field]; ok { field = alias @@ -258,7 +264,7 @@ func SetResourceSelectorClause( }) var clauses []clause.Expression - query, clauses, err = qm.Apply(ctx, *qf, query) + query, clauses, err = qm.Apply(ctx, parsedPEG.queryField, query) if err != nil { return nil, fmt.Errorf("error applying query model: %w", err) } @@ -353,6 +359,25 @@ func SetResourceSelectorClause( return query, nil } +func getParsedResourceSelectorPEG(peg string) (parsedResourceSelectorPEG, error) { + if value, ok := resourceSelectorPEGCache.Get(peg); ok { + return value.(parsedResourceSelectorPEG), nil + } + + qf, err := grammar.ParsePEG(peg) + if err != nil { + return parsedResourceSelectorPEG{}, err + } + + parsed := parsedResourceSelectorPEG{ + queryField: *qf, + flatFields: grammar.FlatFields(qf), + } + resourceSelectorPEGCache.SetDefault(peg, parsed) + + return parsed, nil +} + // queryResourceSelector runs the given resourceSelector and returns the resource ids func queryResourceSelector[T any]( ctx context.Context, diff --git a/query/resource_selector_cache_test.go b/query/resource_selector_cache_test.go new file mode 100644 index 000000000..5066ea7cf --- /dev/null +++ b/query/resource_selector_cache_test.go @@ -0,0 +1,37 @@ +package query + +import ( + "testing" + + "github.com/onsi/gomega" +) + +func TestGetParsedResourceSelectorPEGUsesCache(t *testing.T) { + g := gomega.NewWithT(t) + + resourceSelectorPEGCache.Flush() + + first, err := getParsedResourceSelectorPEG(`name="coredns",type="Kubernetes::Pod"`) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + second, err := getParsedResourceSelectorPEG(`name="coredns",type="Kubernetes::Pod"`) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + g.Expect(second.queryField).To(gomega.Equal(first.queryField)) + g.Expect(second.flatFields).To(gomega.Equal(first.flatFields)) + g.Expect(resourceSelectorPEGCache.ItemCount()).To(gomega.Equal(1)) +} + +func TestGetParsedResourceSelectorPEGCachesByPEGValue(t *testing.T) { + g := gomega.NewWithT(t) + + resourceSelectorPEGCache.Flush() + + _, err := getParsedResourceSelectorPEG(`name="coredns"`) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + _, err = getParsedResourceSelectorPEG(`name="metrics-server"`) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + g.Expect(resourceSelectorPEGCache.ItemCount()).To(gomega.Equal(2)) +} diff --git a/types/resource_selector_canonical.go b/types/resource_selector_canonical.go index a81e928fc..82d7a737e 100644 --- a/types/resource_selector_canonical.go +++ b/types/resource_selector_canonical.go @@ -21,6 +21,10 @@ const ( func (rs ResourceSelector) Canonical() ResourceSelector { out := rs + if !resourceSelectorHasWildcard(out) { + return out + } + if isWildcardValue(out.ID) { out.ID = "" } @@ -139,3 +143,30 @@ func filterWildcardCSV(value string) string { func isWildcardValue(value string) bool { return strings.TrimSpace(value) == wildcardValue } + +func resourceSelectorHasWildcard(rs ResourceSelector) bool { + if strings.Contains(rs.ID, wildcardValue) || + strings.Contains(rs.Namespace, wildcardValue) || + strings.Contains(rs.Scope, wildcardValue) || + strings.Contains(rs.Agent, wildcardValue) || + strings.Contains(rs.TagSelector, wildcardValue) || + strings.Contains(rs.LabelSelector, wildcardValue) || + strings.Contains(rs.FieldSelector, wildcardValue) || + strings.Contains(string(rs.Health), wildcardValue) { + return true + } + + for _, item := range rs.Types { + if strings.Contains(item, wildcardValue) { + return true + } + } + + for _, item := range rs.Statuses { + if strings.Contains(item, wildcardValue) { + return true + } + } + + return false +} diff --git a/types/resource_selector_test.go b/types/resource_selector_test.go index 2b0b68f5f..832d5f6e8 100644 --- a/types/resource_selector_test.go +++ b/types/resource_selector_test.go @@ -614,6 +614,20 @@ var _ = Describe("Resource Selector", func() { Expect(canonical.Statuses).To(Equal(types.Items{"healthy"})) Expect(canonical.Health).To(Equal(types.MatchExpression("warning"))) }) + + It("should keep selectors without wildcard unchanged", func() { + rs := types.ResourceSelector{ + Name: "api-server", + TagSelector: "cluster=aws", + LabelSelector: "app=backend", + FieldSelector: "owner=platform", + Types: []string{"Kubernetes::Pod"}, + Statuses: []string{"healthy"}, + Health: "healthy", + } + + Expect(rs.Canonical()).To(Equal(rs)) + }) }) }) }) From 17ef3164575a4d08a6616f7f2e267142c84f99fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:57:32 +0000 Subject: [PATCH 3/4] test: harden selector cache handling and fast-path coverage Agent-Logs-Url: https://github.com/flanksource/duty/sessions/0e75e66d-86e1-4003-9fdf-356336804af3 Co-authored-by: moshloop <1489660+moshloop@users.noreply.github.com> --- query/resource_selector.go | 5 ++++- types/resource_selector_canonical.go | 16 +++++++-------- types/resource_selector_canonical_test.go | 25 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 types/resource_selector_canonical_test.go diff --git a/query/resource_selector.go b/query/resource_selector.go index bd37f53af..096c84b2a 100644 --- a/query/resource_selector.go +++ b/query/resource_selector.go @@ -361,7 +361,10 @@ func SetResourceSelectorClause( func getParsedResourceSelectorPEG(peg string) (parsedResourceSelectorPEG, error) { if value, ok := resourceSelectorPEGCache.Get(peg); ok { - return value.(parsedResourceSelectorPEG), nil + if parsed, ok := value.(parsedResourceSelectorPEG); ok { + return parsed, nil + } + resourceSelectorPEGCache.Delete(peg) } qf, err := grammar.ParsePEG(peg) diff --git a/types/resource_selector_canonical.go b/types/resource_selector_canonical.go index 82d7a737e..17527bf67 100644 --- a/types/resource_selector_canonical.go +++ b/types/resource_selector_canonical.go @@ -145,14 +145,14 @@ func isWildcardValue(value string) bool { } func resourceSelectorHasWildcard(rs ResourceSelector) bool { - if strings.Contains(rs.ID, wildcardValue) || - strings.Contains(rs.Namespace, wildcardValue) || - strings.Contains(rs.Scope, wildcardValue) || - strings.Contains(rs.Agent, wildcardValue) || - strings.Contains(rs.TagSelector, wildcardValue) || - strings.Contains(rs.LabelSelector, wildcardValue) || - strings.Contains(rs.FieldSelector, wildcardValue) || - strings.Contains(string(rs.Health), wildcardValue) { + if (rs.ID != "" && strings.Contains(rs.ID, wildcardValue)) || + (rs.Namespace != "" && strings.Contains(rs.Namespace, wildcardValue)) || + (rs.Scope != "" && strings.Contains(rs.Scope, wildcardValue)) || + (rs.Agent != "" && strings.Contains(rs.Agent, wildcardValue)) || + (rs.TagSelector != "" && strings.Contains(rs.TagSelector, wildcardValue)) || + (rs.LabelSelector != "" && strings.Contains(rs.LabelSelector, wildcardValue)) || + (rs.FieldSelector != "" && strings.Contains(rs.FieldSelector, wildcardValue)) || + (rs.Health != "" && strings.Contains(string(rs.Health), wildcardValue)) { return true } diff --git a/types/resource_selector_canonical_test.go b/types/resource_selector_canonical_test.go new file mode 100644 index 000000000..d263ee1ab --- /dev/null +++ b/types/resource_selector_canonical_test.go @@ -0,0 +1,25 @@ +package types + +import ( + "testing" + + "github.com/onsi/gomega" +) + +func TestResourceSelectorHasWildcard(t *testing.T) { + g := gomega.NewWithT(t) + + g.Expect(resourceSelectorHasWildcard(ResourceSelector{ + Name: "api-server", + TagSelector: "cluster=aws", + LabelSelector: "app=backend", + FieldSelector: "owner=platform", + Types: Items{"Kubernetes::Pod"}, + Statuses: Items{"healthy"}, + Health: "healthy", + })).To(gomega.BeFalse()) + + g.Expect(resourceSelectorHasWildcard(ResourceSelector{ + TagSelector: "cluster=*", + })).To(gomega.BeTrue()) +} From d37d0bd98cfe925d4ee08b5a4475c5c58d728dce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:11:42 +0000 Subject: [PATCH 4/4] perf: cache parsed selector requirements in resource selector clauses Agent-Logs-Url: https://github.com/flanksource/duty/sessions/0e75e66d-86e1-4003-9fdf-356336804af3 Co-authored-by: moshloop <1489660+moshloop@users.noreply.github.com> --- query/resource_selector.go | 35 ++++++++++++++++++++++----- query/resource_selector_cache_test.go | 25 +++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/query/resource_selector.go b/query/resource_selector.go index 096c84b2a..23783d448 100644 --- a/query/resource_selector.go +++ b/query/resource_selector.go @@ -26,12 +26,17 @@ import ( ) var resourceSelectorPEGCache = cache.New(time.Hour, 2*time.Hour) +var resourceSelectorLabelRequirementsCache = cache.New(time.Hour, 2*time.Hour) type parsedResourceSelectorPEG struct { queryField grammar.QueryField flatFields []string } +type parsedSelectorRequirements struct { + requirements []labels.Requirement +} + type SearchResourcesRequest struct { // Limit the number of results returned per resource type Limit int `json:"limit"` @@ -311,11 +316,10 @@ func SetResourceSelectorClause( if !qm.HasTags { return nil, api.Errorf(api.EINVALID, "tagSelector is not supported for table=%s", table) } else { - parsedTagSelector, err := labels.Parse(resourceSelector.TagSelector) + requirements, err := getSelectorRequirements(resourceSelector.TagSelector) if err != nil { return nil, api.Errorf(api.EINVALID, "failed to parse tag selector: %v", err) } - requirements, _ := parsedTagSelector.Requirements() for _, r := range requirements { query = jsonColumnRequirementsToSQLClause(query, "tags", r) } @@ -327,23 +331,21 @@ func SetResourceSelectorClause( return nil, api.Errorf(api.EINVALID, "labelSelector is not supported for table=%s", table) } - parsedLabelSelector, err := labels.Parse(resourceSelector.LabelSelector) + requirements, err := getSelectorRequirements(resourceSelector.LabelSelector) if err != nil { return nil, api.Errorf(api.EINVALID, "failed to parse label selector: %v", err) } - requirements, _ := parsedLabelSelector.Requirements() for _, r := range requirements { query = jsonColumnRequirementsToSQLClause(query, "labels", r) } } if len(resourceSelector.FieldSelector) > 0 { - parsedFieldSelector, err := labels.Parse(resourceSelector.FieldSelector) + requirements, err := getSelectorRequirements(resourceSelector.FieldSelector) if err != nil { return nil, api.Errorf(api.EINVALID, "failed to parse field selector: %v", err) } - requirements, _ := parsedFieldSelector.Requirements() for _, r := range requirements { query = jsonColumnRequirementsToSQLClause(query, "properties", r) } @@ -381,6 +383,27 @@ func getParsedResourceSelectorPEG(peg string) (parsedResourceSelectorPEG, error) return parsed, nil } +func getSelectorRequirements(selector string) ([]labels.Requirement, error) { + if value, ok := resourceSelectorLabelRequirementsCache.Get(selector); ok { + if cached, ok := value.(parsedSelectorRequirements); ok { + return cached.requirements, nil + } + resourceSelectorLabelRequirementsCache.Delete(selector) + } + + parsedSelector, err := labels.Parse(selector) + if err != nil { + return nil, err + } + + requirements, _ := parsedSelector.Requirements() + resourceSelectorLabelRequirementsCache.SetDefault(selector, parsedSelectorRequirements{ + requirements: requirements, + }) + + return requirements, nil +} + // queryResourceSelector runs the given resourceSelector and returns the resource ids func queryResourceSelector[T any]( ctx context.Context, diff --git a/query/resource_selector_cache_test.go b/query/resource_selector_cache_test.go index 5066ea7cf..e3b934595 100644 --- a/query/resource_selector_cache_test.go +++ b/query/resource_selector_cache_test.go @@ -35,3 +35,28 @@ func TestGetParsedResourceSelectorPEGCachesByPEGValue(t *testing.T) { g.Expect(resourceSelectorPEGCache.ItemCount()).To(gomega.Equal(2)) } + +func TestGetSelectorRequirementsUsesCache(t *testing.T) { + g := gomega.NewWithT(t) + + resourceSelectorLabelRequirementsCache.Flush() + + first, err := getSelectorRequirements("cluster=aws") + g.Expect(err).ToNot(gomega.HaveOccurred()) + + second, err := getSelectorRequirements("cluster=aws") + g.Expect(err).ToNot(gomega.HaveOccurred()) + + g.Expect(second).To(gomega.Equal(first)) + g.Expect(resourceSelectorLabelRequirementsCache.ItemCount()).To(gomega.Equal(1)) +} + +func TestGetSelectorRequirementsReturnsErrorForInvalidSelector(t *testing.T) { + g := gomega.NewWithT(t) + + resourceSelectorLabelRequirementsCache.Flush() + + _, err := getSelectorRequirements("=aws") + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(resourceSelectorLabelRequirementsCache.ItemCount()).To(gomega.Equal(0)) +}