Andrey Meshkov f1791135af Sync v2.11.0
2024-12-05 14:19:25 +03:00

861 lines
25 KiB
Go

// Package profiledb defines interfaces for databases of user profiles.
package profiledb
import (
"context"
"fmt"
"log/slog"
"net/netip"
"path/filepath"
"slices"
"sync"
"time"
"github.com/AdguardTeam/AdGuardDNS/internal/agd"
"github.com/AdguardTeam/AdGuardDNS/internal/agdservice"
"github.com/AdguardTeam/AdGuardDNS/internal/errcoll"
"github.com/AdguardTeam/AdGuardDNS/internal/profiledb/internal"
"github.com/AdguardTeam/AdGuardDNS/internal/profiledb/internal/filecachepb"
"github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/logutil/slogutil"
"github.com/AdguardTeam/golibs/osutil"
"github.com/AdguardTeam/golibs/timeutil"
"github.com/c2h5oh/datasize"
)
// Interface is the local database of user profiles and devices.
//
// NOTE: All returned values must not be modified.
type Interface interface {
// CreateAutoDevice creates a new automatic device for the given profile
// with the given human-readable device ID and device type. All arguments
// must be valid.
CreateAutoDevice(
ctx context.Context,
id agd.ProfileID,
humanID agd.HumanID,
devType agd.DeviceType,
) (p *agd.Profile, d *agd.Device, err error)
// ProfileByDedicatedIP returns the profile and the device identified by its
// dedicated DNS server IP address. ip must be valid.
ProfileByDedicatedIP(
ctx context.Context,
ip netip.Addr,
) (p *agd.Profile, d *agd.Device, err error)
// ProfileByDeviceID returns the profile and the device identified by id.
// id must be valid.
ProfileByDeviceID(
ctx context.Context,
id agd.DeviceID,
) (p *agd.Profile, d *agd.Device, err error)
// ProfileByHumanID returns the profile and the device identified by the
// profile ID and the lowercase version of the human-readable device ID.
// id and humanIDLower must be valid.
ProfileByHumanID(
ctx context.Context,
id agd.ProfileID,
humanIDLower agd.HumanIDLower,
) (p *agd.Profile, d *agd.Device, err error)
// ProfileByLinkedIP returns the profile and the device identified by its
// linked IP address. ip must be valid.
ProfileByLinkedIP(ctx context.Context, ip netip.Addr) (p *agd.Profile, d *agd.Device, err error)
}
// type check
var _ Interface = (*Disabled)(nil)
// Disabled is a profile database that panics on any call.
type Disabled struct{}
// profilesDBUnexpectedCall is a panic message template for lookup methods when
// profiles database is disabled.
const profilesDBUnexpectedCall string = "profiles db: unexpected call to %s"
// CreateAutoDevice implements the [Interface] interface for *Disabled.
func (d *Disabled) CreateAutoDevice(
_ context.Context,
_ agd.ProfileID,
_ agd.HumanID,
_ agd.DeviceType,
) (_ *agd.Profile, _ *agd.Device, _ error) {
panic(fmt.Errorf(profilesDBUnexpectedCall, "CreateAutoDevice"))
}
// ProfileByDedicatedIP implements the [Interface] interface for *Disabled.
func (d *Disabled) ProfileByDedicatedIP(
_ context.Context,
_ netip.Addr,
) (_ *agd.Profile, _ *agd.Device, _ error) {
panic(fmt.Errorf(profilesDBUnexpectedCall, "ProfileByDedicatedIP"))
}
// ProfileByDeviceID implements the [Interface] interface for *Disabled.
func (d *Disabled) ProfileByDeviceID(
_ context.Context,
_ agd.DeviceID,
) (_ *agd.Profile, _ *agd.Device, _ error) {
panic(fmt.Errorf(profilesDBUnexpectedCall, "ProfileByDeviceID"))
}
// ProfileByHumanID implements the [Interface] interface for *Disabled.
func (d *Disabled) ProfileByHumanID(
_ context.Context,
_ agd.ProfileID,
_ agd.HumanIDLower,
) (_ *agd.Profile, _ *agd.Device, _ error) {
panic(fmt.Errorf(profilesDBUnexpectedCall, "ProfileByHumanID"))
}
// ProfileByLinkedIP implements the [Interface] interface for *Disabled.
func (d *Disabled) ProfileByLinkedIP(
_ context.Context,
_ netip.Addr,
) (_ *agd.Profile, _ *agd.Device, _ error) {
panic(fmt.Errorf(profilesDBUnexpectedCall, "ProfileByLinkedIP"))
}
// Config represents the profile database configuration. All fields must not be
// empty.
type Config struct {
// Logger is used for logging the operation of profile database.
Logger *slog.Logger
// Storage returns the data for this profile DB.
Storage Storage
// ErrColl is used to collect errors during refreshes.
ErrColl errcoll.Interface
// Metrics is used for the collection of the user profiles statistics.
Metrics Metrics
// CacheFilePath is the path to the profile cache file. If cacheFilePath is
// the string "none", filesystem cache is disabled.
CacheFilePath string
// FullSyncIvl is the interval between two full synchronizations with the
// storage.
FullSyncIvl time.Duration
// FullSyncRetryIvl is the interval between two retries of full
// synchronizations with the storage.
FullSyncRetryIvl time.Duration
// ResponseSizeEstimate is the estimate of the size of one DNS response for
// the purposes of custom ratelimiting. Responses over this estimate are
// counted as several responses.
ResponseSizeEstimate datasize.ByteSize
}
// Default is the default in-memory implementation of the [Interface] interface
// that can refresh itself from the provided storage. It should be initially
// refreshed before use.
type Default struct {
logger *slog.Logger
// mapsMu protects the profiles, devices, deviceIDToProfileID,
// dedicatedIPToDeviceID, humanIDToDeviceID, and linkedIPToDeviceID maps.
mapsMu *sync.RWMutex
// refreshMu serializes Refresh calls and access to all values used inside
// of it.
refreshMu *sync.Mutex
// errColl is used to collect errors during refreshes.
errColl errcoll.Interface
// metrics is used for the collection of the user profiles statistics.
metrics Metrics
// cache is the filesystem-cache storage used by this profile database.
cache internal.FileCacheStorage
// storage returns the data for this profile DB.
storage Storage
// profiles maps profile IDs to profile records.
profiles map[agd.ProfileID]*agd.Profile
// devices maps device IDs to device records.
devices map[agd.DeviceID]*agd.Device
// dedicatedIPToDeviceID maps dedicated IP addresses to the IDs of their
// devices.
dedicatedIPToDeviceID map[netip.Addr]agd.DeviceID
// deviceIDToProfileID maps device IDs to the ID of their profile.
deviceIDToProfileID map[agd.DeviceID]agd.ProfileID
// humanIDToDeviceID maps human-readable device-ID data to the IDs of the
// devices.
humanIDToDeviceID map[humanIDKey]agd.DeviceID
// linkedIPToDeviceID maps linked IP addresses to the IDs of their devices.
linkedIPToDeviceID map[netip.Addr]agd.DeviceID
// syncTime is the time of the last synchronization point. It is received
// from the storage during a refresh and is then used in consecutive
// requests to the storage, unless it's a full synchronization.
syncTime time.Time
// lastFullSync is the time of the last successful full synchronization.
lastFullSync time.Time
// lastFullSyncError is the time of the last unsuccessful attempt at a full
// synchronization. If the last full synchronization was successful, this
// field is time.Time{}.
lastFullSyncError time.Time
// fullSyncIvl is the interval between two full synchronizations with the
// storage.
fullSyncIvl time.Duration
// fullSyncRetryIvl is the interval between two retries of full
// synchronizations with the storage.
fullSyncRetryIvl time.Duration
}
// humanIDKey is the data necessary to identify a device by the lowercase
// version of its human-readable identifier and the ID of its profile.
type humanIDKey struct {
// lower is the lowercase version of the human-readable device ID.
lower agd.HumanIDLower
// profile is the ID of the profile for the device.
profile agd.ProfileID
}
// New returns a new default in-memory profile database with a filesystem cache.
// The initial refresh is performed immediately with the given timeout, beyond
// which an empty profiledb is returned. If cacheFilePath is the string "none",
// filesystem cache is disabled. db is not nil if the error is from getting the
// file cache.
func New(c *Config) (db *Default, err error) {
var cacheStorage internal.FileCacheStorage
if c.CacheFilePath == "none" {
cacheStorage = internal.EmptyFileCacheStorage{}
} else if ext := filepath.Ext(c.CacheFilePath); ext == ".pb" {
logger := c.Logger.With("cache_type", "pb")
cacheStorage = filecachepb.New(logger, c.CacheFilePath, c.ResponseSizeEstimate)
} else {
return nil, fmt.Errorf("file %q is not protobuf", c.CacheFilePath)
}
db = &Default{
logger: c.Logger,
mapsMu: &sync.RWMutex{},
refreshMu: &sync.Mutex{},
errColl: c.ErrColl,
metrics: c.Metrics,
cache: cacheStorage,
storage: c.Storage,
syncTime: time.Time{},
lastFullSync: time.Time{},
lastFullSyncError: time.Time{},
profiles: make(map[agd.ProfileID]*agd.Profile),
devices: make(map[agd.DeviceID]*agd.Device),
deviceIDToProfileID: make(map[agd.DeviceID]agd.ProfileID),
dedicatedIPToDeviceID: make(map[netip.Addr]agd.DeviceID),
humanIDToDeviceID: make(map[humanIDKey]agd.DeviceID),
linkedIPToDeviceID: make(map[netip.Addr]agd.DeviceID),
fullSyncIvl: c.FullSyncIvl,
fullSyncRetryIvl: c.FullSyncRetryIvl,
}
// TODO(a.garipov): Separate the file cache read and use context from the
// arguments.
ctx := context.Background()
err = db.loadFileCache(ctx)
if err != nil {
db.logger.WarnContext(ctx, "error loading fs cache", slogutil.KeyError, err)
}
return db, nil
}
// type check
var _ agdservice.Refresher = (*Default)(nil)
// Refresh implements the [agdservice.Refresher] interface for *Default. It
// updates the internal maps and the synchronization time using the data it
// receives from the storage.
//
// TODO(a.garipov): Consider splitting the full refresh logic into a separate
// method.
func (db *Default) Refresh(ctx context.Context) (err error) {
db.logger.DebugContext(ctx, "refresh started")
defer db.logger.DebugContext(ctx, "refresh finished")
sinceLastAttempt, isFullSync := db.needsFullSync(ctx)
var profNum, devNum uint
startTime := time.Now()
defer func() {
dur := time.Since(startTime)
isSuccess := err == nil
if !isSuccess {
errcoll.Collect(ctx, db.errColl, db.logger, "refreshing profiledb", err)
}
db.metrics.HandleProfilesUpdate(ctx, &UpdateMetrics{
ProfilesNum: profNum,
DevicesNum: devNum,
Duration: dur,
IsSuccess: isSuccess,
IsFullSync: isFullSync,
})
}()
reqID := agd.NewRequestID()
ctx = agd.WithRequestID(ctx, reqID)
defer func() { err = errors.Annotate(err, "req %s: %w", reqID) }()
db.refreshMu.Lock()
defer db.refreshMu.Unlock()
defer func() {
db.metrics.SetProfilesAndDevicesNum(ctx, uint(len(db.profiles)), uint(len(db.devices)))
}()
resp, err := db.fetchProfiles(ctx, sinceLastAttempt, isFullSync)
if err != nil {
// Don't wrap the error, because it's informative enough as is.
return err
}
profiles := resp.Profiles
devices := resp.Devices
profNum = uint(len(profiles))
devNum = uint(len(devices))
db.logger.DebugContext(
ctx,
"storage request finished",
"req_id", reqID,
"prof_num", profNum,
"dev_num", devNum,
)
db.setProfiles(ctx, profiles, devices, isFullSync)
db.syncTime = resp.SyncTime
if isFullSync {
db.lastFullSync = time.Now()
db.lastFullSyncError = time.Time{}
err = db.cache.Store(ctx, &internal.FileCache{
SyncTime: resp.SyncTime,
Profiles: profiles,
Devices: devices,
Version: internal.FileCacheVersion,
})
if err != nil {
return fmt.Errorf("saving cache: %w", err)
}
}
return nil
}
// fetchProfiles fetches the profiles and devices from the storage. It returns
// the response and the error, if any. If isFullSync is true, the last full
// synchronization error time is updated on error. It must only be called under
// the refreshMu lock.
func (db *Default) fetchProfiles(
ctx context.Context,
sinceLastAttempt time.Duration,
isFullSync bool,
) (sr *StorageProfilesResponse, err error) {
syncTime := db.syncTime
if isFullSync {
db.logger.InfoContext(ctx, "full sync", "since_last_attempt", timeutil.Duration{
Duration: sinceLastAttempt,
})
syncTime = time.Time{}
}
sr, err = db.storage.Profiles(ctx, &StorageProfilesRequest{
SyncTime: syncTime,
})
if err == nil {
return sr, nil
}
if isFullSync {
db.lastFullSyncError = time.Now()
}
if errors.Is(err, context.DeadlineExceeded) {
db.metrics.IncrementSyncTimeouts(ctx, isFullSync)
}
return nil, fmt.Errorf("updating profiles: %w", err)
}
// needsFullSync determines if a full synchronization is necessary. If the last
// full synchronization was successful, it returns true if it's time for a new
// one. Otherwise, it returns true if it's time for a retry.
func (db *Default) needsFullSync(ctx context.Context) (sinceFull time.Duration, isFull bool) {
lastFull := db.lastFullSync
sinceFull = time.Since(lastFull)
if db.lastFullSyncError.IsZero() {
return sinceFull, sinceFull >= db.fullSyncIvl
}
db.logger.WarnContext(
ctx,
"previous sync finished with error",
"since_last_successful_sync", timeutil.Duration{
Duration: sinceFull,
},
"last_successful_sync_time", lastFull,
)
sinceLastError := time.Since(db.lastFullSyncError)
return sinceLastError, sinceLastError >= db.fullSyncRetryIvl
}
// loadFileCache loads the profiles data from the filesystem cache.
func (db *Default) loadFileCache(ctx context.Context) (err error) {
start := time.Now()
l := db.logger.With("cache_op", "load")
l.InfoContext(ctx, "initial loading")
c, err := db.cache.Load(ctx)
if err != nil {
if errors.Is(err, internal.CacheVersionError) {
l.WarnContext(ctx, "cache version error", slogutil.KeyError, err)
return nil
}
// Don't wrap the error, because it's informative enough as is.
return err
} else if c == nil {
l.InfoContext(ctx, "no cache")
return nil
}
profNum, devNum := len(c.Profiles), len(c.Devices)
l.InfoContext(
ctx,
"cache loaded",
"version", c.Version,
"prof_num", profNum,
"dev_num", devNum,
"elapsed", timeutil.Duration{
Duration: time.Since(start),
},
)
if profNum == 0 || devNum == 0 {
l.InfoContext(ctx, "cache is empty; not setting profiles")
return nil
}
db.setProfiles(ctx, c.Profiles, c.Devices, true)
db.syncTime, db.lastFullSync = c.SyncTime, c.SyncTime
return nil
}
// setProfiles adds or updates the data for all profiles and devices.
func (db *Default) setProfiles(
ctx context.Context,
profiles []*agd.Profile,
devices []*agd.Device,
isFullSync bool,
) {
db.mapsMu.Lock()
defer db.mapsMu.Unlock()
if isFullSync {
clear(db.profiles)
clear(db.devices)
clear(db.dedicatedIPToDeviceID)
clear(db.deviceIDToProfileID)
clear(db.humanIDToDeviceID)
clear(db.linkedIPToDeviceID)
}
for _, p := range profiles {
db.profiles[p.ID] = p
for _, devID := range p.DeviceIDs {
db.deviceIDToProfileID[devID] = p.ID
}
if p.Deleted {
// The deleted profiles are included in profiles slice only if
// setProfiles is called when loading from the storage and it is not
// a full sync. If setProfiles is called when loading from cache,
// the profiles slice does not include the deleted profiles, so we
// can update metric correctly.
db.metrics.IncrementDeleted(ctx)
}
}
db.setDevices(ctx, devices)
}
// setDevices adds or updates the data for the given devices. It assumes that
// db.mapsMu is locked for writing.
func (db *Default) setDevices(ctx context.Context, devices []*agd.Device) {
for _, d := range devices {
devID := d.ID
db.devices[devID] = d
for _, dedIP := range d.DedicatedIPs {
db.dedicatedIPToDeviceID[dedIP] = devID
}
if d.LinkedIP != (netip.Addr{}) {
db.linkedIPToDeviceID[d.LinkedIP] = devID
}
if d.HumanIDLower == "" {
continue
}
profID, ok := db.deviceIDToProfileID[devID]
if !ok {
db.logger.WarnContext(ctx, "no profile id for device", "dev_id", devID)
continue
}
db.humanIDToDeviceID[humanIDKey{
lower: d.HumanIDLower,
profile: profID,
}] = devID
}
}
// type check
var _ Interface = (*Default)(nil)
// CreateAutoDevice implements the [Interface] interface for *Default.
func (db *Default) CreateAutoDevice(
ctx context.Context,
id agd.ProfileID,
humanID agd.HumanID,
devType agd.DeviceType,
) (p *agd.Profile, d *agd.Device, err error) {
var ok bool
func() {
db.mapsMu.RLock()
defer db.mapsMu.RUnlock()
p, ok = db.profiles[id]
}()
if !ok {
return nil, nil, ErrProfileNotFound
}
if !p.AutoDevicesEnabled {
// If the user did not enable the automatic devices feature, treat it
// the same as if this profile did not exist.
return nil, nil, ErrProfileNotFound
}
resp, err := db.storage.CreateAutoDevice(ctx, &StorageCreateAutoDeviceRequest{
ProfileID: id,
HumanID: humanID,
DeviceType: devType,
})
if err != nil {
return nil, nil, err
}
d = resp.Device
func() {
db.mapsMu.Lock()
defer db.mapsMu.Unlock()
// TODO(a.garipov): Technically, we must also update p.DeviceIDs, but
// this is hard to do without races, since all methods of the profile
// database return values as opposed to clones. This can cause issues
// when the same device is used both by a HumanID and a DeviceID, but we
// consider this situation to be relatively rare.
db.setDevices(ctx, []*agd.Device{d})
}()
return p, d, nil
}
// ProfileByDedicatedIP implements the [Interface] interface for *Default. ip
// must be valid.
func (db *Default) ProfileByDedicatedIP(
ctx context.Context,
ip netip.Addr,
) (p *agd.Profile, d *agd.Device, err error) {
// Do not use errors.Annotate here, because it allocates even when the error
// is nil. Also do not use fmt.Errorf in a defer, because it allocates when
// a device is not found, which is the most common case.
db.mapsMu.RLock()
defer db.mapsMu.RUnlock()
id, ok := db.dedicatedIPToDeviceID[ip]
if !ok {
return nil, nil, ErrDeviceNotFound
}
const errPrefix = "profile by device dedicated ip"
p, d, err = db.profileByDeviceID(ctx, id)
if err != nil {
if errors.Is(err, ErrDeviceNotFound) {
// Probably, the device has been deleted. Remove it from our
// profile DB in a goroutine, since that requires a write lock.
go db.removeDedicatedIP(ctx, ip)
}
// Don't add the device ID to the error here, since it is already added
// by profileByDeviceID.
return nil, nil, fmt.Errorf("%s: %w", errPrefix, err)
}
if !slices.Contains(d.DedicatedIPs, ip) {
// Perhaps, the device has changed its dedicated IPs. Remove it from
// our profile DB in a goroutine, since that requires a write lock.
go db.removeDedicatedIP(ctx, ip)
return nil, nil, fmt.Errorf(
"%s: rechecking dedicated ips: %w",
errPrefix,
ErrDeviceNotFound,
)
}
return p, d, nil
}
// ProfileByDeviceID implements the [Interface] interface for *Default.
func (db *Default) ProfileByDeviceID(
ctx context.Context,
id agd.DeviceID,
) (p *agd.Profile, d *agd.Device, err error) {
db.mapsMu.RLock()
defer db.mapsMu.RUnlock()
return db.profileByDeviceID(ctx, id)
}
// profileByDeviceID returns the profile and the device by the ID of the device,
// if found. It assumes that db.mapsMu is locked for reading.
func (db *Default) profileByDeviceID(
ctx context.Context,
id agd.DeviceID,
) (p *agd.Profile, d *agd.Device, err error) {
// Do not use [errors.Annotate] here, because it allocates even when the
// error is nil. Also do not use fmt.Errorf in a defer, because it
// allocates when a device is not found, which is the most common case.
profID, ok := db.deviceIDToProfileID[id]
if !ok {
return nil, nil, ErrDeviceNotFound
}
p, ok = db.profiles[profID]
if !ok {
// We have an older device record with a deleted profile. Remove it
// from our profile DB in a goroutine, since that requires a write lock.
go db.removeDevice(ctx, id)
return nil, nil, ErrProfileNotFound
}
// Reinspect the devices in the profile record to make sure that the device
// is still attached to this profile.
for _, profDevID := range p.DeviceIDs {
if profDevID == id {
d = db.devices[id]
break
}
}
if d == nil {
if !p.AutoDevicesEnabled {
// Perhaps, the device has been deleted from this profile. May
// happen when the device was found by a linked IP. Remove it from
// our profile DB in a goroutine, since that requires a write lock.
//
// Do not do that for profiles with enabled autodevices, though.
// See the TODO in [Default.CreateAutoDevice].
go db.removeDevice(ctx, id)
}
return nil, nil, fmt.Errorf("rechecking devices: %w", ErrDeviceNotFound)
}
return p, d, nil
}
// removeDevice removes the device with the given ID from the database. It is
// intended to be used as a goroutine.
func (db *Default) removeDevice(ctx context.Context, id agd.DeviceID) {
defer slogutil.RecoverAndExit(ctx, db.logger, osutil.ExitCodeFailure)
db.mapsMu.Lock()
defer db.mapsMu.Unlock()
delete(db.deviceIDToProfileID, id)
}
// removeDedicatedIP removes the device link for the given dedicated IP address
// from the profile database. It is intended to be used as a goroutine.
func (db *Default) removeDedicatedIP(ctx context.Context, ip netip.Addr) {
defer slogutil.RecoverAndExit(ctx, db.logger, osutil.ExitCodeFailure)
db.mapsMu.Lock()
defer db.mapsMu.Unlock()
delete(db.dedicatedIPToDeviceID, ip)
}
// ProfileByHumanID implements the [Interface] interface for *Default.
func (db *Default) ProfileByHumanID(
ctx context.Context,
id agd.ProfileID,
humanID agd.HumanIDLower,
) (p *agd.Profile, d *agd.Device, err error) {
// Do not use errors.Annotate here, because it allocates even when the error
// is nil. Also do not use fmt.Errorf in a defer, because it allocates when
// a device is not found, which is the most common case.
db.mapsMu.RLock()
defer db.mapsMu.RUnlock()
// NOTE: It's important to check the profile and return ErrProfileNotFound
// here to prevent the device finder from trying to create a device for a
// profile that doesn't exist.
p, ok := db.profiles[id]
if !ok {
return nil, nil, ErrProfileNotFound
}
k := humanIDKey{
lower: humanID,
profile: id,
}
devID, ok := db.humanIDToDeviceID[k]
if !ok {
return nil, nil, ErrDeviceNotFound
}
const errPrefix = "profile by human id"
p, d, err = db.profileByDeviceID(ctx, devID)
if err != nil {
if errors.Is(err, ErrDeviceNotFound) {
// Probably, the device has been deleted. Remove it from our
// profile DB in a goroutine, since that requires a write lock.
go db.removeHumanID(ctx, k)
}
// Don't add the device ID to the error here, since it is already added
// by profileByDeviceID.
return nil, nil, fmt.Errorf("%s: %w", errPrefix, err)
}
if humanID != d.HumanIDLower {
// Perhaps, the device has changed its human ID, for example by being
// transformed into a normal device.. Remove it from our profile DB in
// a goroutine, since that requires a write lock.
go db.removeHumanID(ctx, k)
return nil, nil, fmt.Errorf("%s: rechecking human id: %w", errPrefix, ErrDeviceNotFound)
}
return p, d, nil
}
// removeHumanID removes the device link for the given key from the profile
// database. It is intended to be used as a goroutine.
func (db *Default) removeHumanID(ctx context.Context, k humanIDKey) {
defer slogutil.RecoverAndExit(ctx, db.logger, osutil.ExitCodeFailure)
db.mapsMu.Lock()
defer db.mapsMu.Unlock()
delete(db.humanIDToDeviceID, k)
}
// ProfileByLinkedIP implements the [Interface] interface for *Default. ip must
// be valid.
func (db *Default) ProfileByLinkedIP(
ctx context.Context,
ip netip.Addr,
) (p *agd.Profile, d *agd.Device, err error) {
// Do not use errors.Annotate here, because it allocates even when the error
// is nil. Also do not use fmt.Errorf in a defer, because it allocates when
// a device is not found, which is the most common case.
db.mapsMu.RLock()
defer db.mapsMu.RUnlock()
id, ok := db.linkedIPToDeviceID[ip]
if !ok {
return nil, nil, ErrDeviceNotFound
}
const errPrefix = "profile by device linked ip"
p, d, err = db.profileByDeviceID(ctx, id)
if err != nil {
if errors.Is(err, ErrDeviceNotFound) {
// Probably, the device has been deleted. Remove it from our
// profile DB in a goroutine, since that requires a write lock.
go db.removeLinkedIP(ctx, ip)
}
// Don't add the device ID to the error here, since it is already added
// by profileByDeviceID.
return nil, nil, fmt.Errorf("%s: %w", errPrefix, err)
}
if d.LinkedIP == (netip.Addr{}) {
return nil, nil, fmt.Errorf(
"%s: device does not have linked ip: %w",
errPrefix,
ErrDeviceNotFound,
)
} else if d.LinkedIP != ip {
// The linked IP has changed. Remove it from our profile DB in a
// goroutine, since that requires a write lock.
go db.removeLinkedIP(ctx, ip)
return nil, nil, fmt.Errorf(
"%s: %q does not match: %w",
errPrefix,
d.LinkedIP,
ErrDeviceNotFound,
)
}
return p, d, nil
}
// removeLinkedIP removes the device link for the given linked IP address from
// the profile database. It is intended to be used as a goroutine.
func (db *Default) removeLinkedIP(ctx context.Context, ip netip.Addr) {
defer slogutil.RecoverAndExit(ctx, db.logger, osutil.ExitCodeFailure)
db.mapsMu.Lock()
defer db.mapsMu.Unlock()
delete(db.linkedIPToDeviceID, ip)
}