cidr: only probe the prefix lengths that have a decision

In stream mode nothing caches a negative result per IP, so the exact IP
lookup misses on every legitimate request and each one fell through to
GetCIDR, which probed every possible prefix length: 33 cache reads for an
IPv4 client, 129 for an IPv6 one, even when no range decision existed at
all. On the local cache that is wasted work on the request path; with
redis it is 33 to 129 sequential round trips per request.

Keep the set of prefix lengths that have at least one decision under a
single key, written before the decision itself, and probe only those.
Measured cache reads per request: 1 with no range decision (was 33 / 129),
2 with a single /24 in use, 4 with four prefix lengths in use.

The set only grows, so a deleted or expired decision leaves a length
behind that costs one extra read rather than risking an unmatched
decision, and it is written with an effectively infinite duration since it
has to outlive every decision it describes. If it is ever missing while
decisions live (a redis eviction under maxmemory), range decisions stop
matching until the next one arrives; it is the hottest key of the
namespace, so an LRU policy evicts it last.
This commit is contained in:
mhx
2026-08-03 13:40:41 +02:00
parent 1b1247823c
commit adffe64319
2 changed files with 95 additions and 13 deletions
+56 -11
View File
@@ -6,6 +6,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv"
"strings"
"sync/atomic" "sync/atomic"
ttl_map "github.com/leprosus/golang-ttl-map" ttl_map "github.com/leprosus/golang-ttl-map"
@@ -27,8 +29,17 @@ const (
CacheMiss = "cache:miss" CacheMiss = "cache:miss"
// CacheUnreachable error string when cache is unreachable. // CacheUnreachable error string when cache is unreachable.
CacheUnreachable = "cache:unreachable" CacheUnreachable = "cache:unreachable"
// cidrPrefix store all cidr within the same key. // cidrPrefix namespaces a CIDR decision, one cache key per CIDR.
cidrPrefix = "cidr:" cidrPrefix = "cidr:"
// cidrPrefixLensKey holds the prefix lengths that have a decision, so a lookup probes
// only those. Its absence means no CIDR decision was ever stored.
cidrPrefixLensKey = "cidrprefixlens"
// cidrPrefixLensSeparator separates the prefix lengths in cidrPrefixLensKey.
cidrPrefixLensSeparator = ","
// cidrPrefixLensDuration has to outlive every decision it describes, so it is
// effectively infinite. It cannot be zero: the local cache ignores a zero duration
// and redis rejects a non positive EX.
cidrPrefixLensDuration = 10 * 365 * 24 * 60 * 60
) )
//nolint:gochecknoglobals //nolint:gochecknoglobals
@@ -160,14 +171,15 @@ func (c *Client) DeleteCIDR(cidr string) {
} }
// GetCIDR checks if an IP matches a CIDR decision in the cache. // GetCIDR checks if an IP matches a CIDR decision in the cache.
// Only probes the prefix lengths that have a decision: it is on the request path.
func (c *Client) GetCIDR(ipStr string) (string, error) { func (c *Client) GetCIDR(ipStr string) (string, error) {
keys := ip.CIDRKeys(ipStr) prefixLens, err := c.cache.get(cidrPrefixLensKey)
if keys == nil { if err != nil {
return "", errors.New(CacheMiss) return "", err
} }
for _, key := range keys { for _, key := range ip.CIDRLookupKeys(ipStr, parsePrefixLens(prefixLens)) {
value, err := c.cache.get(cidrPrefix + key) value, getErr := c.cache.get(cidrPrefix + key)
if err == nil { if getErr == nil {
return value, nil return value, nil
} }
} }
@@ -176,10 +188,43 @@ func (c *Client) GetCIDR(ipStr string) (string, error) {
// SetCIDR stores a CIDR decision in the cache. // SetCIDR stores a CIDR decision in the cache.
func (c *Client) SetCIDR(cidr, value string, duration int64) { func (c *Client) SetCIDR(cidr, value string, duration int64) {
cidr = ip.NormalizeCIDR(cidr) normalized := ip.NormalizeCIDR(cidr)
if cidr == "" { prefixLen := ip.CIDRPrefixLen(cidr)
if normalized == "" || prefixLen < 0 {
return return
} }
c.cache.set(cidrPrefix+cidr, value, duration) // Publish the length first, or a concurrent lookup misses the decision.
c.log.Debug(fmt.Sprintf("cache:SetCIDR cidr:%v value:%v duration:%vs", cidr, value, duration)) c.addCIDRPrefixLen(prefixLen)
c.cache.set(cidrPrefix+normalized, value, duration)
c.log.Debug(fmt.Sprintf("cache:SetCIDR cidr:%v value:%v duration:%vs", normalized, value, duration))
}
// addCIDRPrefixLen records a prefix length in the set probed on lookup. The set only grows:
// a stale length costs one extra read, dropping one too early leaves decisions unmatched.
func (c *Client) addCIDRPrefixLen(prefixLen int) {
prefixLens, err := c.cache.get(cidrPrefixLensKey)
if err == nil {
for _, known := range parsePrefixLens(prefixLens) {
if known == prefixLen {
return
}
}
prefixLens += cidrPrefixLensSeparator + strconv.Itoa(prefixLen)
} else {
prefixLens = strconv.Itoa(prefixLen)
}
c.cache.set(cidrPrefixLensKey, prefixLens, cidrPrefixLensDuration)
c.log.Debug(fmt.Sprintf("cache:addCIDRPrefixLen prefixLens:%v", prefixLens))
}
// parsePrefixLens decodes the set of prefix lengths stored in cidrPrefixLensKey.
func parsePrefixLens(value string) []int {
fields := strings.Split(value, cidrPrefixLensSeparator)
prefixLens := make([]int, 0, len(fields))
for _, field := range fields {
if prefixLen, err := strconv.Atoi(field); err == nil {
prefixLens = append(prefixLens, prefixLen)
}
}
return prefixLens
} }
+39 -2
View File
@@ -5,6 +5,11 @@ import (
"strings" "strings"
) )
const (
maxIPv4PrefixLen = 32
maxIPv6PrefixLen = 128
)
// CIDRKeys returns all possible CIDR prefixes of an IP, from the most specific (/32 for IPv4, /128 for IPv6) to the least specific (/0). // CIDRKeys returns all possible CIDR prefixes of an IP, from the most specific (/32 for IPv4, /128 for IPv6) to the least specific (/0).
func CIDRKeys(ipStr string) []string { func CIDRKeys(ipStr string) []string {
parsed, maxBits := parseForPrefix(ipStr) parsed, maxBits := parseForPrefix(ipStr)
@@ -18,6 +23,28 @@ func CIDRKeys(ipStr string) []string {
return keys return keys
} }
// CIDRLookupKeys returns the keys of the CIDRs containing an IP for the given prefix lengths
// only, most specific first. Duplicates and lengths of the other family are skipped.
func CIDRLookupKeys(ipStr string, prefixLens []int) []string {
parsed, maxBits := parseForPrefix(ipStr)
if parsed == nil {
return nil
}
var wanted [maxIPv6PrefixLen + 1]bool
for _, bits := range prefixLens {
if bits >= 0 && bits <= maxBits {
wanted[bits] = true
}
}
keys := make([]string, 0, len(prefixLens))
for bits := maxBits; bits >= 0; bits-- {
if wanted[bits] {
keys = append(keys, cidrKey(parsed, bits, maxBits))
}
}
return keys
}
// NormalizeCIDR parses a CIDR string and returns its normalized form, or an empty string if invalid. // NormalizeCIDR parses a CIDR string and returns its normalized form, or an empty string if invalid.
func NormalizeCIDR(cidrStr string) string { func NormalizeCIDR(cidrStr string) string {
_, ipNet, err := net.ParseCIDR(strings.TrimSpace(cidrStr)) _, ipNet, err := net.ParseCIDR(strings.TrimSpace(cidrStr))
@@ -27,6 +54,16 @@ func NormalizeCIDR(cidrStr string) string {
return ipNet.String() return ipNet.String()
} }
// CIDRPrefixLen returns the prefix length of a CIDR, or -1 if it is not a valid CIDR.
func CIDRPrefixLen(cidrStr string) int {
_, ipNet, err := net.ParseCIDR(strings.TrimSpace(cidrStr))
if err != nil {
return -1
}
prefixLen, _ := ipNet.Mask.Size()
return prefixLen
}
// parseForPrefix returns the IP in the native form of its family, and that family's bit length. // parseForPrefix returns the IP in the native form of its family, and that family's bit length.
func parseForPrefix(ipStr string) (net.IP, int) { func parseForPrefix(ipStr string) (net.IP, int) {
parsed := net.ParseIP(ipStr) parsed := net.ParseIP(ipStr)
@@ -34,9 +71,9 @@ func parseForPrefix(ipStr string) (net.IP, int) {
return nil, 0 return nil, 0
} }
if parsed4 := parsed.To4(); parsed4 != nil { if parsed4 := parsed.To4(); parsed4 != nil {
return parsed4, 32 return parsed4, maxIPv4PrefixLen
} }
return parsed.To16(), 128 return parsed.To16(), maxIPv6PrefixLen
} }
// cidrKey builds the key of the CIDR of bits length containing the IP. // cidrKey builds the key of the CIDR of bits length containing the IP.