Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions pkg/daemon/beacon_discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const (
beaconRefreshInterval = routing.BeaconRefreshInterval
beaconRefreshJitter = routing.BeaconRefreshJitter
beaconCacheFilename = routing.BeaconCacheFilename
beaconCacheMaxAge = routing.BeaconCacheMaxAge
)

type beaconLister = routing.BeaconLister
Expand Down Expand Up @@ -166,6 +167,23 @@ func (d *Daemon) beaconRefreshTick(firstTick bool) {
if firstTick {
cached, cacheErr := loadBeaconCache(d.config.IdentityPath)
if cacheErr == nil && len(cached) > 0 {
// Reject the cache if it is older than BeaconCacheMaxAge.
// A stale cache keeps the daemon trying offline beacons
// indefinitely; fall through to the bootstrap list instead.
savedAt, savedAtErr := routing.BeaconCacheSavedAt(d.config.IdentityPath)
if savedAtErr == nil {
age := time.Since(savedAt)
if age > beaconCacheMaxAge {
slog.Warn("beacon discovery: rejecting stale on-disk cache",
"err", err,
"cache_age", age.Truncate(time.Second),
"max_age", beaconCacheMaxAge,
)
slog.Debug("beacon discovery skipped (registry error, stale cache)",
"err", err)
return
}
}
slog.Info("beacon discovery: using on-disk cache (registry unreachable)",
"err", err, "cached_count", len(cached))
discovered = cached
Expand Down
27 changes: 27 additions & 0 deletions pkg/daemon/routing/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ const BeaconRefreshInterval = 60 * time.Second
// The first refresh fires at t = rand[0..BeaconRefreshJitter).
const BeaconRefreshJitter = 10 * time.Second

// BeaconCacheMaxAge is the maximum age of an on-disk beacon cache
// before it is considered stale and rejected in favor of the
// operator-configured bootstrap list.
const BeaconCacheMaxAge = 1 * time.Hour

// BeaconCacheFilename is the on-disk fallback used when the registry
// is unreachable at cold-start. Lives next to the identity file.
const BeaconCacheFilename = "beacons.json"
Expand Down Expand Up @@ -142,6 +147,28 @@ func LoadBeaconCache(identityPath string) ([]string, error) {
return FilterUnreachable(entry.Addrs), nil
}

// BeaconCacheSavedAt reads the SavedAt timestamp from the on-disk cache
// without deserialising the full addr list. Returns (time.Time{}, nil)
// when the file does not exist.
func BeaconCacheSavedAt(identityPath string) (time.Time, error) {
path := BeaconCachePath(identityPath)
if path == "" {
return time.Time{}, nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return time.Time{}, nil
}
return time.Time{}, fmt.Errorf("read beacon cache for SavedAt: %w", err)
}
var entry BeaconCacheEntry
if err := json.Unmarshal(data, &entry); err != nil {
return time.Time{}, fmt.Errorf("parse beacon cache for SavedAt: %w", err)
}
return entry.SavedAt, nil
}

// BeaconSelectionState tracks the daemon's beacon picks across refresh
// ticks. Pure data — the refresh logic mutates it under its mutex,
// and the daemon hot path reads via GetCurrentPick().
Expand Down
Loading