mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-15 18:19:57 +00:00
* perf(routes): batch cross-IATA and hash route search (#216) One search fired a /routes/cross request per IATA pair (138k on 2026-10-08). Route search now takes an optional iatas list and runs a fixed number of index-backed queries. /info advertises built-in minimum client versions. * chore: bump version to 2.0.4
375 lines
17 KiB
Go
375 lines
17 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package cache
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
keyIATAs = "beacon:iatas"
|
|
keyIATAPrefix = "beacon:iata:"
|
|
keyIATABorderPrefix = "beacon:iata:border:"
|
|
keyRegions = "beacon:regions"
|
|
keyRegionPrefix = "beacon:region:"
|
|
keyRegionSlugPrefix = "beacon:region:slug:"
|
|
keyScopeNames = "beacon:scope:names"
|
|
keyScopeStats = "beacon:scope:stats"
|
|
keyScopeByNamePrefix = "beacon:scope:name:"
|
|
keyStatsOverviewPrefix = "beacon:stats:overview:"
|
|
keyStatsObservationsPrefix = "beacon:stats:observations:"
|
|
keySignalStatsPrefix = "beacon:stats:signal:"
|
|
keyStatsSeriesPrefix = "beacon:stats:series:"
|
|
keyPathStatsPrefix = "beacon:stats:paths:"
|
|
keyStatsBreakdownPrefix = "beacon:stats:breakdown:"
|
|
keyStatsTopNodesPrefix = "beacon:stats:top-nodes:"
|
|
keyStatsTopObsPrefix = "beacon:stats:top-observers:"
|
|
keyStatsTopAdvPrefix = "beacon:stats:top-advertisers:"
|
|
keyStatsClockDriftPrefix = "beacon:stats:clock-drift:"
|
|
keyStatsTopTalkersPrefix = "beacon:stats:top-talkers:"
|
|
keyStatsNodeTypes = "beacon:stats:node-types:"
|
|
keyRadioPresetsPrefix = "beacon:radio-presets:"
|
|
keyNodePrefix = "beacon:node:"
|
|
keyNodeNeighborsPrefix = "beacon:node:neighbors:"
|
|
keyNodesByIDsPrefix = "beacon:nodes:ids:"
|
|
keyObserverPrefix = "beacon:observer:"
|
|
keyObserverScopesPrefix = "beacon:observer:scopes:"
|
|
keyObserverActivityPrefix = "beacon:observer:activity:"
|
|
)
|
|
|
|
// observerActivityTTL is deliberately short: activity is a live chart, not reference data.
|
|
const observerActivityTTL = 60 * time.Second
|
|
|
|
// CachedReader wraps an api.Reader with a Redis caching layer.
|
|
// It implements api.Reader and is a drop-in replacement for db.Store
|
|
// at the wiring point in main.go.
|
|
type CachedReader struct {
|
|
inner api.Reader
|
|
c *Client
|
|
ttl CacheTTLs
|
|
rev revisionMemo
|
|
now func() time.Time
|
|
}
|
|
|
|
// CacheTTLs holds the resolved per-category TTLs for the cache layer.
|
|
// All fields should be non-zero — use ResolveTTLs to build this from
|
|
// config with fallback to the global TTL and then the default.
|
|
type CacheTTLs struct {
|
|
Stats time.Duration
|
|
Reference time.Duration
|
|
Nodes time.Duration
|
|
Observers time.Duration
|
|
}
|
|
|
|
// NewCachedReader returns an api.Reader that transparently caches responses
|
|
// using the provided Redis client and TTL configuration. inner is the
|
|
// underlying db.Store that is called on cache misses.
|
|
func NewCachedReader(inner api.Reader, c *Client, ttl CacheTTLs) api.Reader {
|
|
return &CachedReader{
|
|
inner: inner,
|
|
c: c,
|
|
ttl: ttl,
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
// InvalidateScopeNames makes newly committed catalogue names visible to every scope read.
|
|
func (cr *CachedReader) InvalidateScopeNames(ctx context.Context) {
|
|
cr.c.del(ctx, keyScopeNames)
|
|
cr.c.delPrefix(ctx, keyScopeByNamePrefix)
|
|
cr.c.delPrefix(ctx, keyScopeStats)
|
|
}
|
|
|
|
// InvalidateIATABorder makes an imported or removed MeshMapper boundary visible.
|
|
func (cr *CachedReader) InvalidateIATABorder(ctx context.Context, iata string) {
|
|
cr.c.del(ctx, keyIATABorderPrefix+iata)
|
|
}
|
|
|
|
// InvalidateIATAs makes imported MeshMapper names and locations visible to every IATA read.
|
|
func (cr *CachedReader) InvalidateIATAs(ctx context.Context) {
|
|
cr.c.del(ctx, keyIATAs)
|
|
cr.c.delPrefix(ctx, keyIATAPrefix)
|
|
}
|
|
|
|
// InvalidateRegions makes imported MeshMapper regions visible to every region read.
|
|
func (cr *CachedReader) InvalidateRegions(ctx context.Context) {
|
|
cr.c.del(ctx, keyRegions)
|
|
cr.c.delPrefix(ctx, keyRegionPrefix)
|
|
}
|
|
|
|
// InvalidateNode removes the cached entries for a node by UUID.
|
|
// Should be called from the ingest path after a node upsert.
|
|
func (cr *CachedReader) InvalidateNode(ctx context.Context, nodeID uuid.UUID) {
|
|
id := nodeID.String()
|
|
cr.c.del(ctx, keyNodePrefix+id, keyNodeNeighborsPrefix+id)
|
|
}
|
|
|
|
// InvalidateObserver removes the cached entries for an observer by UUID.
|
|
// Should be called from the ingest path after an observer upsert.
|
|
func (cr *CachedReader) InvalidateObserver(ctx context.Context, observerID uuid.UUID) {
|
|
id := observerID.String()
|
|
cr.c.del(ctx, keyObserverPrefix+id, keyObserverScopesPrefix+id)
|
|
}
|
|
|
|
// ListIATAs implements [api.Reader].
|
|
func (cr *CachedReader) ListIATAs(ctx context.Context) ([]api.IATA, error) {
|
|
return getOrSet(ctx, cr.c, keyIATAs, cr.ttl.Reference, func(ctx context.Context) ([]api.IATA, error) {
|
|
return cr.inner.ListIATAs(ctx)
|
|
})
|
|
}
|
|
|
|
// GetIATA implements [api.Reader].
|
|
func (cr *CachedReader) GetIATA(ctx context.Context, iata string) (*api.IATA, error) {
|
|
return getOrSet(ctx, cr.c, keyIATAPrefix+iata, cr.ttl.Reference, func(ctx context.Context) (*api.IATA, error) {
|
|
return cr.inner.GetIATA(ctx, iata)
|
|
})
|
|
}
|
|
|
|
// GetIATABorder implements [api.Reader].
|
|
func (cr *CachedReader) GetIATABorder(ctx context.Context, iata string) (json.RawMessage, error) {
|
|
return getOrSet(ctx, cr.c, keyIATABorderPrefix+iata, cr.ttl.Reference, func(ctx context.Context) (json.RawMessage, error) {
|
|
return cr.inner.GetIATABorder(ctx, iata)
|
|
})
|
|
}
|
|
|
|
// ListRegions implements [api.Reader].
|
|
func (cr *CachedReader) ListRegions(ctx context.Context) ([]api.RegionSummary, error) {
|
|
return getOrSet(ctx, cr.c, keyRegions, cr.ttl.Reference, func(ctx context.Context) ([]api.RegionSummary, error) {
|
|
return cr.inner.ListRegions(ctx)
|
|
})
|
|
}
|
|
|
|
// GetRegion implements [api.Reader].
|
|
func (cr *CachedReader) GetRegion(ctx context.Context, regionID int32) (*api.Region, error) {
|
|
return getOrSet(ctx, cr.c, fmt.Sprintf("%s%d", keyRegionPrefix, regionID), cr.ttl.Reference, func(ctx context.Context) (*api.Region, error) {
|
|
return cr.inner.GetRegion(ctx, regionID)
|
|
})
|
|
}
|
|
|
|
// GetRegionBySlug implements [api.Reader].
|
|
func (cr *CachedReader) GetRegionBySlug(ctx context.Context, slug string) (*api.Region, error) {
|
|
return getOrSet(ctx, cr.c, keyRegionSlugPrefix+slug, cr.ttl.Reference, func(ctx context.Context) (*api.Region, error) {
|
|
return cr.inner.GetRegionBySlug(ctx, slug)
|
|
})
|
|
}
|
|
|
|
// GetScopeNames implements [api.Reader].
|
|
func (cr *CachedReader) GetScopeNames(ctx context.Context) ([]string, error) {
|
|
return getOrSet(ctx, cr.c, keyScopeNames, cr.ttl.Reference, func(ctx context.Context) ([]string, error) {
|
|
return cr.inner.GetScopeNames(ctx)
|
|
})
|
|
}
|
|
|
|
// GetObserverComparison passes arbitrary observer/time combinations through;
|
|
// these explicit queries should not fill the shared statistics cache.
|
|
func (cr *CachedReader) GetObserverComparison(ctx context.Context, a, b uuid.UUID, since, until time.Time, iatas []string) (*api.ObserverComparison, error) {
|
|
return cr.inner.GetObserverComparison(ctx, a, b, since, until, iatas)
|
|
}
|
|
|
|
// GetStatsNodeTypes implements [api.Reader].
|
|
func (cr *CachedReader) GetStatsNodeTypes(ctx context.Context, iatas []string) ([]api.NodeTypeCount, error) {
|
|
segment := "all"
|
|
if len(iatas) > 0 {
|
|
sorted := append([]string(nil), iatas...)
|
|
sort.Strings(sorted)
|
|
segment = strings.Join(sorted, ",")
|
|
}
|
|
key := fmt.Sprintf("%s%s", keyStatsNodeTypes, segment)
|
|
return getOrSet(ctx, cr.c, key, cr.ttl.Stats, func(ctx context.Context) ([]api.NodeTypeCount, error) {
|
|
return cr.inner.GetStatsNodeTypes(ctx, iatas)
|
|
})
|
|
}
|
|
|
|
// GetStatsClockDrift implements [api.Reader].
|
|
func (cr *CachedReader) GetStatsClockDrift(ctx context.Context, iatas []string, limit int32) ([]api.ClockDriftEntry, error) {
|
|
segment := "all"
|
|
if len(iatas) > 0 {
|
|
sorted := append([]string(nil), iatas...)
|
|
sort.Strings(sorted)
|
|
segment = strings.Join(sorted, ",")
|
|
}
|
|
key := fmt.Sprintf("%s%s:%d", keyStatsClockDriftPrefix, segment, limit)
|
|
return getOrSet(ctx, cr.c, key, cr.ttl.Stats, func(ctx context.Context) ([]api.ClockDriftEntry, error) {
|
|
return cr.inner.GetStatsClockDrift(ctx, iatas, limit)
|
|
})
|
|
}
|
|
|
|
// GetRadioPresets implements [api.Reader].
|
|
func (cr *CachedReader) GetRadioPresets(ctx context.Context, preset string, iatas []string) ([]api.RadioPreset, error) {
|
|
segment := "all"
|
|
if len(iatas) > 0 {
|
|
sorted := append([]string(nil), iatas...)
|
|
sort.Strings(sorted)
|
|
segment = strings.Join(sorted, ",")
|
|
}
|
|
key := fmt.Sprintf("%s%s:%s", keyRadioPresetsPrefix, preset, segment)
|
|
return getOrSet(ctx, cr.c, key, cr.ttl.Stats, func(ctx context.Context) ([]api.RadioPreset, error) {
|
|
return cr.inner.GetRadioPresets(ctx, preset, iatas)
|
|
})
|
|
}
|
|
|
|
// GetNode implements [api.Reader].
|
|
func (cr *CachedReader) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error) {
|
|
return getOrSet(ctx, cr.c, keyNodePrefix+nodeID.String(), cr.ttl.Nodes, func(ctx context.Context) (*api.Node, error) {
|
|
return cr.inner.GetNode(ctx, nodeID)
|
|
})
|
|
}
|
|
|
|
// GetNodeNeighbors implements [api.Reader].
|
|
func (cr *CachedReader) GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error) {
|
|
return getOrSet(ctx, cr.c, keyNodeNeighborsPrefix+nodeID.String(), cr.ttl.Nodes, func(ctx context.Context) ([]api.NodeNeighbor, error) {
|
|
return cr.inner.GetNodeNeighbors(ctx, nodeID)
|
|
})
|
|
}
|
|
|
|
// GetNodesByIDs implements [api.Reader].
|
|
func (cr *CachedReader) GetNodesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*api.ResolvedNode, error) {
|
|
strs := make([]string, len(ids))
|
|
for i, id := range ids {
|
|
strs[i] = id.String()
|
|
}
|
|
sort.Strings(strs)
|
|
key := keyNodesByIDsPrefix + strings.Join(strs, ",")
|
|
return getOrSet(ctx, cr.c, key, cr.ttl.Nodes, func(ctx context.Context) (map[uuid.UUID]*api.ResolvedNode, error) {
|
|
return cr.inner.GetNodesByIDs(ctx, ids)
|
|
})
|
|
}
|
|
|
|
// GetObserver implements [api.Reader].
|
|
func (cr *CachedReader) GetObserver(ctx context.Context, observerID uuid.UUID) (*api.Observer, error) {
|
|
return getOrSet(ctx, cr.c, keyObserverPrefix+observerID.String(), cr.ttl.Observers, func(ctx context.Context) (*api.Observer, error) {
|
|
return cr.inner.GetObserver(ctx, observerID)
|
|
})
|
|
}
|
|
|
|
// GetObserverScopes implements [api.Reader].
|
|
func (cr *CachedReader) GetObserverScopes(ctx context.Context, observerID uuid.UUID) ([]string, error) {
|
|
return getOrSet(ctx, cr.c, keyObserverScopesPrefix+observerID.String(), cr.ttl.Observers, func(ctx context.Context) ([]string, error) {
|
|
return cr.inner.GetObserverScopes(ctx, observerID)
|
|
})
|
|
}
|
|
|
|
// GetObserverTelemetry implements [api.Reader].
|
|
func (cr *CachedReader) GetObserverTelemetry(ctx context.Context, observerID uuid.UUID, since, until time.Time, afterID int64) (*api.ObserverTelemetry, error) {
|
|
return cr.inner.GetObserverTelemetry(ctx, observerID, since, until, afterID)
|
|
}
|
|
|
|
// GetObserverTelemetryBucketed implements [api.Reader].
|
|
func (cr *CachedReader) GetObserverTelemetryBucketed(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]api.ObserverTelemetryPoint, error) {
|
|
return cr.inner.GetObserverTelemetryBucketed(ctx, observerID, since, until, bucketHours)
|
|
}
|
|
|
|
// GetObserverActivity implements [api.Reader].
|
|
func (cr *CachedReader) GetObserverActivity(ctx context.Context, observerID uuid.UUID, window, interval time.Duration, until time.Time) (*api.ObserverActivity, error) {
|
|
if !until.IsZero() {
|
|
until = until.UTC().Truncate(interval)
|
|
}
|
|
key := keyObserverActivityPrefix + observerID.String() + ":" + window.String() + ":" + interval.String() + ":" + until.UTC().Format(time.RFC3339Nano)
|
|
return getOrSet(ctx, cr.c, key, observerActivityTTL, func(ctx context.Context) (*api.ObserverActivity, error) {
|
|
return cr.inner.GetObserverActivity(ctx, observerID, window, interval, until)
|
|
})
|
|
}
|
|
|
|
// GetPacket implements [api.Reader].
|
|
func (cr *CachedReader) GetPacket(ctx context.Context, packetHash []byte) (*api.Packet, error) {
|
|
return cr.inner.GetPacket(ctx, packetHash)
|
|
}
|
|
|
|
// GetChannel implements [api.Reader].
|
|
func (cr *CachedReader) GetChannel(ctx context.Context, channelID int32) (*api.Channel, error) {
|
|
return cr.inner.GetChannel(ctx, channelID)
|
|
}
|
|
|
|
// GetTraceByTag implements [api.Reader].
|
|
func (cr *CachedReader) GetTraceByTag(ctx context.Context, tag string) (*api.TraceDetail, error) {
|
|
return cr.inner.GetTraceByTag(ctx, tag)
|
|
}
|
|
|
|
// ListChannels implements [api.Reader].
|
|
func (cr *CachedReader) ListChannels(ctx context.Context, limit int32, hash []byte, iatas []string, keyKnown *bool, cursor int64, pageCursor *api.ChannelCursor) (api.ChannelPage, error) {
|
|
return cr.inner.ListChannels(ctx, limit, hash, iatas, keyKnown, cursor, pageCursor)
|
|
}
|
|
|
|
// ListChannelMessages implements [api.Reader].
|
|
func (cr *CachedReader) ListChannelMessages(ctx context.Context, channelID *int32, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) {
|
|
return cr.inner.ListChannelMessages(ctx, channelID, since, limit, iatas, scope, cursor)
|
|
}
|
|
|
|
// ListChannelMessagesByHash implements [api.Reader].
|
|
func (cr *CachedReader) ListChannelMessagesByHash(ctx context.Context, hash []byte, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) {
|
|
return cr.inner.ListChannelMessagesByHash(ctx, hash, since, limit, iatas, scope, cursor)
|
|
}
|
|
|
|
// ListMessagesAfterID implements [api.Reader].
|
|
func (cr *CachedReader) ListMessagesAfterID(ctx context.Context, afterID int64, iatas []string, scope string, limit int32) ([]api.ChannelMessage, error) {
|
|
return cr.inner.ListMessagesAfterID(ctx, afterID, iatas, scope, limit)
|
|
}
|
|
|
|
// ListNodes implements [api.Reader].
|
|
func (cr *CachedReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, pubkeyPrefix, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) {
|
|
return cr.inner.ListNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, pubkeyPrefix, name, scope, cursor, limit, includeNeighbors)
|
|
}
|
|
|
|
// ListNodeObservations implements [api.Reader].
|
|
func (cr *CachedReader) ListNodeObservations(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (api.Page[api.PacketObservationSummary], error) {
|
|
return cr.inner.ListNodeObservations(ctx, nodeID, cursor, limit)
|
|
}
|
|
|
|
// ListObservers implements [api.Reader].
|
|
func (cr *CachedReader) ListObservers(ctx context.Context, iatas []string, observerType, broker, status, name, scope string, cursor int64, limit int32) (api.Page[api.ObserverSummary], error) {
|
|
return cr.inner.ListObservers(ctx, iatas, observerType, broker, status, name, scope, cursor, limit)
|
|
}
|
|
|
|
// ListObserverAdverts implements [api.Reader].
|
|
func (cr *CachedReader) ListObserverAdverts(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error) {
|
|
return cr.inner.ListObserverAdverts(ctx, observerID, cursor, limit)
|
|
}
|
|
|
|
// ListPackets implements [api.Reader].
|
|
func (cr *CachedReader) ListPackets(ctx context.Context, payloadTypes, routeTypes []int16, iatas []string, scopes []string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) {
|
|
return cr.inner.ListPackets(ctx, payloadTypes, routeTypes, iatas, scopes, since, until, cursor, limit)
|
|
}
|
|
|
|
// ListPacketsAfterID implements [api.Reader].
|
|
func (cr *CachedReader) ListPacketsAfterID(ctx context.Context, afterObservationID int64, payloadType, routeType int16, iatas []string, scope string, limit int32) ([]api.PacketSummary, error) {
|
|
return cr.inner.ListPacketsAfterID(ctx, afterObservationID, payloadType, routeType, iatas, scope, limit)
|
|
}
|
|
|
|
// ListKnownRoutes implements [api.Reader].
|
|
func (cr *CachedReader) ListKnownRoutes(ctx context.Context, iata string, hopCount int32, cursor time.Time, cursorID int64, limit int32) ([]api.KnownRoute, error) {
|
|
return cr.inner.ListKnownRoutes(ctx, iata, hopCount, cursor, cursorID, limit)
|
|
}
|
|
|
|
// SearchKnownRoutes implements [api.Reader].
|
|
func (cr *CachedReader) SearchKnownRoutes(ctx context.Context, iatas []string, fromHash, toHash string) ([]api.KnownRoute, error) {
|
|
return cr.inner.SearchKnownRoutes(ctx, iatas, fromHash, toHash)
|
|
}
|
|
|
|
// Precise cursor/window combinations are intentionally passed through, like route lists.
|
|
func (cr *CachedReader) GetRouteEvidence(ctx context.Context, iata, key string, query api.RouteEvidenceQuery) (*api.RouteEvidence, error) {
|
|
return cr.inner.GetRouteEvidence(ctx, iata, key, query)
|
|
}
|
|
|
|
// SearchCrossIATARoutes implements [api.Reader].
|
|
func (cr *CachedReader) SearchCrossIATARoutes(ctx context.Context, q api.CrossRouteSearch) ([]api.CrossIATARoute, error) {
|
|
return cr.inner.SearchCrossIATARoutes(ctx, q)
|
|
}
|
|
|
|
// ListTraceTags implements [api.Reader].
|
|
func (cr *CachedReader) ListTraceTags(ctx context.Context, iatas []string, scope, traceType string, since, until time.Time, cursor time.Time, cursorTag string, limit int32) ([]api.TraceTagSummary, error) {
|
|
return cr.inner.ListTraceTags(ctx, iatas, scope, traceType, since, until, cursor, cursorTag, limit)
|
|
}
|
|
|
|
func (cr *CachedReader) ListObserverDirectory(ctx context.Context, query api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
|
|
return cr.inner.ListObserverDirectory(ctx, query)
|
|
}
|