From 53e1d00a19bba2cb0603ae0e862edc866f68b350 Mon Sep 17 00:00:00 2001 From: Ben Grewell Date: Thu, 30 Jul 2026 22:35:26 +0000 Subject: [PATCH 1/3] fix(nrfcache): treat profiles without SUPI ranges as unrestricted A cached NF profile that declares no SUPI ranges was rejected whenever a discovery query carried a SUPI, so UDM, PCF and AUSF lookups could never be served from cache and every request fell through to the NRF. The NRF's own discovery filter matches those profiles: its supi filter is an $or over "a range contains the SUPI", "supiRanges is null" and "supiRanges is absent". The cache therefore selected a different set of profiles than the NRF it caches, so a cached lookup and a live discovery disagreed for the same query. Match the NRF semantics in MatchUdmProfile, MatchPcfProfile and MatchAusfProfile, and cover both the unrestricted case and the still-filtered case with regression tests. Measured on an SD-Core deployment whose single UDM/PCF register without SUPI ranges: AMF NRF cache misses per registration dropped from ~4 to 0, NfProfile queries from 21 to 16 per registration, and NRF CPU from 77 ms to 51 ms per registration. Signed-off-by: Ben Grewell --- nrfcache/match_filters.go | 20 +++++--- nrfcache/nrfcache_test.go | 103 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/nrfcache/match_filters.go b/nrfcache/match_filters.go index 1006200a..20c64eb8 100644 --- a/nrfcache/match_filters.go +++ b/nrfcache/match_filters.go @@ -187,9 +187,10 @@ func extractSupiNumber(supi string) string { func MatchAusfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.ApiSearchNFInstancesRequest) (bool, error) { supi := opts.GetSupi() if supi != nil { + // Unrestricted when no SUPI ranges are declared; see MatchPcfProfile. if profile.AusfInfo == nil || len(profile.AusfInfo.SupiRanges) == 0 { - logger.NrfcacheLog.Debugf("ausf match failed: no SUPI ranges for %s", profile.NfInstanceId) - return false, nil + logger.NrfcacheLog.Debugf("ausf match successful (unrestricted: no SUPI ranges) for %s", profile.NfInstanceId) + return true, nil } matchFound := matchSupiRange(*supi, profile.AusfInfo.SupiRanges) @@ -279,9 +280,15 @@ func MatchAmfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A func MatchPcfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.ApiSearchNFInstancesRequest) (bool, error) { supi := opts.GetSupi() if supi != nil { + // A profile declaring no SUPI ranges is unrestricted and serves every + // SUPI. The NRF's own discovery filter encodes this as an $or over + // "a range contains the SUPI" / "supiRanges is null" / "supiRanges is + // absent" (nrf producer/nf_discovery.go, [Query-18] supi). Rationale: + // the cache must select the same profiles as the NRF it caches, + // otherwise a cached lookup and a live discovery disagree. if profile.PcfInfo == nil || len(profile.PcfInfo.SupiRanges) == 0 { - logger.NrfcacheLog.Infof("pcf match found = false (no SUPI ranges)") - return false, nil + logger.NrfcacheLog.Debugf("pcf match found = true (unrestricted: no SUPI ranges)") + return true, nil } matchFound := matchSupiRange(*supi, profile.PcfInfo.SupiRanges) @@ -297,9 +304,10 @@ func MatchPcfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A func MatchUdmProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.ApiSearchNFInstancesRequest) (bool, error) { supi := opts.GetSupi() if supi != nil { + // Unrestricted when no SUPI ranges are declared; see MatchPcfProfile. if profile.UdmInfo == nil || len(profile.UdmInfo.GetSupiRanges()) == 0 { - logger.NrfcacheLog.Infof("udm match found = false (no SUPI ranges)") - return false, nil + logger.NrfcacheLog.Debugf("udm match found = true (unrestricted: no SUPI ranges)") + return true, nil } matchFound := matchSupiRange(*supi, profile.UdmInfo.GetSupiRanges()) diff --git a/nrfcache/nrfcache_test.go b/nrfcache/nrfcache_test.go index 1e82a957..be26e7a2 100644 --- a/nrfcache/nrfcache_test.go +++ b/nrfcache/nrfcache_test.go @@ -1147,3 +1147,106 @@ func TestAmfProfileMatching(t *testing.T) { }) } } + +// TestMatchProfileWithoutSupiRangesIsUnrestricted covers profiles that declare +// no SUPI ranges. Such a profile serves every SUPI, so a SUPI-filtered +// discovery must select it. The NRF's own discovery filter encodes this as an +// $or over "a range contains the SUPI" / "supiRanges is null" / "supiRanges is +// absent"; a matcher that rejected these profiles would make cached lookups +// disagree with a live discovery against the same NRF. +func TestMatchProfileWithoutSupiRangesIsUnrestricted(t *testing.T) { + const supi = "imsi-208930100007500" + + testCases := []struct { + name string + matcher MatchFilter + profile models.NFProfileDiscovery + }{ + { + name: "udm_info_without_supi_ranges", + matcher: MatchUdmProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "UDM-no-ranges", + NfType: models.NFTYPE_UDM, + UdmInfo: &models.UdmInfo{}, + }, + }, + { + name: "udm_info_absent", + matcher: MatchUdmProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "UDM-no-info", + NfType: models.NFTYPE_UDM, + }, + }, + { + name: "pcf_info_without_supi_ranges", + matcher: MatchPcfProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "PCF-no-ranges", + NfType: models.NFTYPE_PCF, + PcfInfo: &models.PcfInfo{}, + }, + }, + { + name: "pcf_info_absent", + matcher: MatchPcfProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "PCF-no-info", + NfType: models.NFTYPE_PCF, + }, + }, + { + name: "ausf_info_without_supi_ranges", + matcher: MatchAusfProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "AUSF-no-ranges", + NfType: models.NFTYPE_AUSF, + AusfInfo: &models.AusfInfo{}, + }, + }, + { + name: "ausf_info_absent", + matcher: MatchAusfProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "AUSF-no-info", + NfType: models.NFTYPE_AUSF, + }, + }, + } + + param := Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Supi(supi) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + match, err := tc.matcher(&tc.profile, param) + if err != nil { + t.Fatalf("matcher returned error: %v", err) + } + if !match { + t.Errorf("profile %s declares no SUPI ranges and must match SUPI %s, got no match", + tc.profile.NfInstanceId, supi) + } + }) + } +} + +// TestMatchProfileWithSupiRangesStillFiltersOut guards the complementary case: +// once a profile declares ranges, a SUPI outside them must not match. +func TestMatchProfileWithSupiRangesStillFiltersOut(t *testing.T) { + ranges := []models.SupiRange{{Start: openapi.PtrString("100000000000000"), End: openapi.PtrString("100000000000009")}} + param := Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Supi("imsi-208930100007500") + + profile := models.NFProfileDiscovery{ + NfInstanceId: "UDM-ranged", + NfType: models.NFTYPE_UDM, + UdmInfo: &models.UdmInfo{SupiRanges: ranges}, + } + match, err := MatchUdmProfile(&profile, param) + if err != nil { + t.Fatalf("MatchUdmProfile returned error: %v", err) + } + if match { + t.Error("SUPI outside the declared ranges must not match") + } +} From 2211d72af776012dc8b49c381f28ea100547ed96 Mon Sep 17 00:00:00 2001 From: Ben Grewell Date: Fri, 31 Jul 2026 00:03:52 +0000 Subject: [PATCH 2/3] fix(nrfcache): register a match filter for UDR NrfCache.get drops any cached profile whose NfType has no entry in matchFilters, and UDR had no entry. Cached UDR discovery therefore always returned an empty result, so every UDM and PCF subscriber data access fell through to a live NRF query. That miss is expensive beyond the extra query: handleLookup holds the cache write lock across the NRF round trip, so a permanently-missing NF type serialises every concurrent discovery for it behind one network call. UDR is resolved on every subscriber data access, which makes it the most frequently discovered NF in the core. Add MatchUdrProfile, mirroring the NRF's own UDR supi filter, and register it. Cover the SUPI-range and unrestricted cases, and assert the filter is registered so a future NF type is not silently dropped again. Measured on SD-Core, registration throughput over the same load sweep rose from 22 to ~185 attaches/s, and MongoDB operations per registration fell from 50 to 25 as the NfProfile and urilist queries left the hot path. Signed-off-by: Ben Grewell --- nrfcache/match_filters.go | 25 ++++++++++++++++++ nrfcache/nrfcache_test.go | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/nrfcache/match_filters.go b/nrfcache/match_filters.go index 20c64eb8..f4d7a62f 100644 --- a/nrfcache/match_filters.go +++ b/nrfcache/match_filters.go @@ -34,6 +34,7 @@ var matchFilters = MatchFilters{ models.NFTYPE_PCF: MatchPcfProfile, models.NFTYPE_NSSF: MatchNssfProfile, models.NFTYPE_UDM: MatchUdmProfile, + models.NFTYPE_UDR: MatchUdrProfile, models.NFTYPE_AMF: MatchAmfProfile, } @@ -319,3 +320,27 @@ func MatchUdmProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A logger.NrfcacheLog.Infof("udm match found = true (no SUPI filter)") return true, nil } + +// MatchUdrProfile selects UDR profiles for a SUPI-filtered discovery. +// +// Without an entry in matchFilters a UDR profile is dropped unconditionally, +// so a cached UDR lookup returns nothing and every UDM/PCF data access falls +// through to a live NRF query. Rationale: UDR is resolved on every subscriber +// data access, which makes it the most frequently discovered NF in the core. +func MatchUdrProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.ApiSearchNFInstancesRequest) (bool, error) { + supi := opts.GetSupi() + if supi == nil { + logger.NrfcacheLog.Debugf("udr match successful (no SUPI filter) for %s", profile.NfInstanceId) + return true, nil + } + + // Unrestricted when no SUPI ranges are declared; see MatchPcfProfile. + if profile.UdrInfo == nil || len(profile.UdrInfo.GetSupiRanges()) == 0 { + logger.NrfcacheLog.Debugf("udr match successful (unrestricted: no SUPI ranges) for %s", profile.NfInstanceId) + return true, nil + } + + matchFound := matchSupiRange(*supi, profile.UdrInfo.GetSupiRanges()) + logger.NrfcacheLog.Debugf("udr match found = %v for %s", matchFound, profile.NfInstanceId) + return matchFound, nil +} diff --git a/nrfcache/nrfcache_test.go b/nrfcache/nrfcache_test.go index be26e7a2..a538b473 100644 --- a/nrfcache/nrfcache_test.go +++ b/nrfcache/nrfcache_test.go @@ -1213,6 +1213,23 @@ func TestMatchProfileWithoutSupiRangesIsUnrestricted(t *testing.T) { NfType: models.NFTYPE_AUSF, }, }, + { + name: "udr_info_without_supi_ranges", + matcher: MatchUdrProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "UDR-no-ranges", + NfType: models.NFTYPE_UDR, + UdrInfo: &models.UdrInfo{}, + }, + }, + { + name: "udr_info_absent", + matcher: MatchUdrProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "UDR-no-info", + NfType: models.NFTYPE_UDR, + }, + }, } param := Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Supi(supi) @@ -1250,3 +1267,41 @@ func TestMatchProfileWithSupiRangesStillFiltersOut(t *testing.T) { t.Error("SUPI outside the declared ranges must not match") } } + +// TestUdrProfileIsSelectableFromCache guards the registration of UDR in +// matchFilters. A profile whose NfType has no registered filter is dropped by +// NrfCache.get, so an unregistered UDR made every cached UDR lookup return +// empty and forced a live NRF query on every subscriber data access. +func TestUdrProfileIsSelectableFromCache(t *testing.T) { + if _, ok := matchFilters[models.NFTYPE_UDR]; !ok { + t.Fatal("UDR has no entry in matchFilters, so cached UDR discovery can never return a profile") + } + + profile := models.NFProfileDiscovery{ + NfInstanceId: "UDR-1", + NfType: models.NFTYPE_UDR, + UdrInfo: &models.UdrInfo{ + SupiRanges: []models.SupiRange{ + {Start: openapi.PtrString("208930100007500"), End: openapi.PtrString("208930100007599")}, + }, + }, + } + + inRange := Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Supi("imsi-208930100007550") + match, err := MatchUdrProfile(&profile, inRange) + if err != nil { + t.Fatalf("MatchUdrProfile returned error: %v", err) + } + if !match { + t.Error("SUPI inside the declared range must match") + } + + outOfRange := Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Supi("imsi-208930100009999") + match, err = MatchUdrProfile(&profile, outOfRange) + if err != nil { + t.Fatalf("MatchUdrProfile returned error: %v", err) + } + if match { + t.Error("SUPI outside the declared range must not match") + } +} From 8dd88fb3d913cf4b7426f0599ff0dfd9a9ba3c22 Mon Sep 17 00:00:00 2001 From: Ben Grewell Date: Thu, 6 Aug 2026 16:00:54 -0700 Subject: [PATCH 3/3] Update nrfcache/nrfcache_test.go Reorder struct to satisfy field alignment lint Co-authored-by: Gabriel Arrobo Signed-off-by: Ben Grewell --- nrfcache/nrfcache_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nrfcache/nrfcache_test.go b/nrfcache/nrfcache_test.go index a538b473..caa61d12 100644 --- a/nrfcache/nrfcache_test.go +++ b/nrfcache/nrfcache_test.go @@ -1158,9 +1158,9 @@ func TestMatchProfileWithoutSupiRangesIsUnrestricted(t *testing.T) { const supi = "imsi-208930100007500" testCases := []struct { - name string - matcher MatchFilter profile models.NFProfileDiscovery + matcher MatchFilter + name string }{ { name: "udm_info_without_supi_ranges",